Current section
Files
Jump to
Current section
Files
lib/toon_ex/decode/structural_parser_v2.ex
defmodule ToonEx.Decode.StructuralParserV2 do
@moduledoc """
Structural parser for TOON format that handles indentation-based nesting.
This parser processes TOON input by analyzing indentation levels and building
a hierarchical structure from the flat text representation.
"""
alias ToonEx.Decode.Parser
alias ToonEx.DecodeError
# Performance: Direct binary constants to eliminate function call overhead
@colon ":"
@space " "
@comma ","
@tab "\t"
@pipe "|"
@double_quote "\""
# Performance: Inline hot functions to reduce function call overhead during decoding
@compile {:inline,
parse_value: 1,
do_parse_value: 1,
parse_number_or_string: 1,
unquote_string: 1,
unquote_key: 1,
extract_delimiter: 1,
parse_fields: 2,
parse_delimited_values: 2,
remove_list_marker: 1,
line_kind: 1,
empty_list_item_value?: 1,
build_map_with_keys: 2,
build_map_from_fields_and_values: 3,
put_key: 4,
empty_map: 1,
drop_lines_at_level: 2,
build_object_with_nested: 4,
get_nested_indent: 3,
parse_remaining_fields: 2,
normalize_parsed_number: 2,
normalize_decimal_number: 1,
has_decimal_or_exponent?: 1,
detect_delimiter: 2,
peek_next_indent: 1,
get_first_content_indent: 1}
# Pre-compiled regex patterns for performance - avoids recompilation on every call
@tabular_array_header_regex ~r/^((?:"[^"]*"|[\w.]+))(\[\d+.*\])\{([^}]+)\}:$/
@root_tabular_array_regex ~r/^\[((\d+))([^\]]*)\]\{([^}]+)\}:$/
@list_array_header_regex ~r/^((?:"[^"]*"|[\w.]+))(\[\d+[^\]]*\]):$/
@array_length_regex ~r/\[(\d+)/
@array_header_with_values_regex ~r/\[(\d+)([^\]]*)\]$/
@inline_array_header_regex ~r/^\[([^\]]+)\]:\s*(.*)$/
@array_header_with_colon_regex ~r/^[\w"]+(\[(\d+)[^\]]*\]):/
# Module-level regex patterns for structural matching
@tabular_header_pattern ~r/^(?:"[^"]*"|[\w.]+)\[\d+.*\]\{[^}]+\}:$/
@list_header_pattern ~r/^(?:"[^"]*"|[\w.]+)\[\d+.*\]:$/
@inline_array_pattern ~r/^\[.*?\]: .+/
@list_array_header_pattern ~r/^\[\d+[^\]]*\]:$/
@field_pattern ~r/^(?:"(?:[^"\\]|\\.)*"|[\w.-]+)(?:\[[^\]]*\])?\s*:/
@tabular_header_regex ~r/^((?:"[^"]*"|[\w.]+))(\[\d+.*\])\{([^}]+)\}:$/
@list_array_regex ~r/^((?:"[^"]*"|[\w.]+))\[(\d+).*\]:$/
@type line_info :: %{
content: String.t(),
indent: non_neg_integer(),
line_number: non_neg_integer(),
original: String.t()
}
@type parse_metadata :: %{
quoted_keys: MapSet.t(String.t()),
key_order: list(String.t())
}
@doc """
Parses TOON input string into a structured format.
Returns a tuple of {result, metadata} where metadata contains quoted_keys and key_order.
"""
@spec parse(String.t(), map()) :: {:ok, {term(), parse_metadata()}} | {:error, DecodeError.t()}
# In parse/2, reverse key_order once before returning:
def parse(input, opts) when is_binary(input) do
lines = preprocess_lines(input)
if opts.strict, do: validate_indentation(lines, opts)
initial_metadata = %{quoted_keys: MapSet.new(), key_order: []}
{result, metadata} =
case lines do
[] -> {%{}, initial_metadata}
_ -> parse_structure(lines, 0, opts, initial_metadata)
end
# Reverse once here — O(N) — instead of appending O(N) times above.
final_metadata = %{metadata | key_order: Enum.reverse(metadata.key_order)}
{:ok, {result, final_metadata}}
rescue
e in DecodeError ->
{:error, e}
e ->
{:error,
DecodeError.exception(message: "Parse failed: #{Exception.message(e)}", input: input)}
end
# Preprocess input into line information structures
# Jason-style: :binary.split returns plain binaries (no String struct overhead),
# tail-recursive preprocessing avoids intermediate Enum.with_index list.
defp preprocess_lines(input) do
input
|> :binary.split("\n", [:global])
|> do_preprocess_lines([], 1)
|> drop_trailing_blank()
end
defp do_preprocess_lines([], acc, _idx), do: :lists.reverse(acc)
defp do_preprocess_lines([line | rest], acc, idx) do
do_preprocess_lines(rest, [build_line_info(line, idx) | acc], idx + 1)
end
# Jason-style: binary pattern matching for leading space counting.
# Replaces String.trim_leading (which allocates a new binary) with a
# single-pass scan that returns the sub-binary reference and count.
# The sub-binary from pattern matching is O(1) — no copy.
defp build_line_info(line, line_num) do
{trimmed, indent} = trim_leading_spaces(line, 0)
# Jason-style: binary scan for blank detection replaces String.trim_trailing
# which would allocate a trimmed copy just to check if it's empty.
is_blank = trimmed == "" or do_all_whitespace?(trimmed)
%{content: trimmed, indent: indent, line_number: line_num, original: line, is_blank: is_blank}
end
# Strip leading spaces/tabs and count how many were removed.
# Returns {rest_binary, count}.
@compile {:inline, trim_leading_spaces: 2}
defp trim_leading_spaces(<<?\s, rest::binary>>, count), do: trim_leading_spaces(rest, count + 1)
defp trim_leading_spaces(<<?\t, rest::binary>>, count), do: trim_leading_spaces(rest, count + 1)
defp trim_leading_spaces(rest, count), do: {rest, count}
# Check if a binary contains only whitespace (spaces and tabs).
# Replaces `String.trim_trailing(str) == ""` to avoid allocating a trimmed copy.
@compile {:inline, do_all_whitespace?: 1}
defp do_all_whitespace?(<<>>), do: true
defp do_all_whitespace?(<<?\s, rest::binary>>), do: do_all_whitespace?(rest)
defp do_all_whitespace?(<<?\t, rest::binary>>), do: do_all_whitespace?(rest)
defp do_all_whitespace?(_), do: false
# Drop trailing blank lines by finding the last non-blank index
defp drop_trailing_blank(lines) do
last_non_blank =
lines
|> Enum.with_index()
|> Enum.reduce(-1, fn {line, idx}, acc ->
if line.is_blank, do: acc, else: idx
end)
if last_non_blank < 0, do: [], else: Enum.take(lines, last_non_blank + 1)
end
# Validate indentation in strict mode
defp validate_indentation(lines, opts) do
Enum.each(lines, fn line ->
# Skip blank lines
unless line.is_blank do
# Check for tab characters in INDENTATION only (not in content after the key/value starts)
# Use binary pattern matching for O(1) check instead of String.to_charlist + Enum.take_while
if has_tab_in_leading_whitespace?(line.original) do
raise DecodeError,
message: "Tab characters are not allowed in indentation (strict mode)",
input: line.original
end
# Check if indent is a multiple of indent_size
if line.indent > 0 and rem(line.indent, opts.indent_size) != 0 do
raise DecodeError,
message: "Indentation must be a multiple of #{opts.indent_size} spaces (strict mode)",
input: line.original
end
end
end)
end
# Performance: Binary pattern matching to detect tab in leading whitespace - O(1) for space-only, O(n) worst case
defp has_tab_in_leading_whitespace?(<<?\t, _rest::binary>>), do: true
defp has_tab_in_leading_whitespace?(<<?\s, rest::binary>>),
do: has_tab_in_leading_whitespace?(rest)
defp has_tab_in_leading_whitespace?(_), do: false
# Parse a structure starting from given lines at a specific indent level
defp parse_structure(lines, base_indent, opts, metadata) do
{root_type, _} = detect_root_type(lines)
case root_type do
:root_array ->
parse_root_array(lines, opts, metadata)
:root_primitive ->
parse_root_primitive(lines, opts, metadata)
:object ->
parse_object_lines(lines, base_indent, opts, metadata)
end
end
# Detect if the root is an array or object or primitive
# Performance: Uses pre-compiled module-level regexes instead of inline ~r patterns
defp detect_root_type([%{content: content} | rest]) do
cond do
# Root array header patterns
String.starts_with?(content, "[") ->
{:root_array, :inline}
String.match?(content, @root_tabular_array_regex) ->
{:root_array, :tabular}
# Single line -> check if it's a primitive or key-value
rest == [] ->
cond do
# Tabular array header key[N]{fields}: ... — must be detected before
# the generic key-value check because {fields} sits between [N] and ":"
# and breaks the simpler regex.
String.match?(content, @tabular_header_regex) ->
# Route to :object so parse_entry_line raises DecodeError on the
# missing / malformed data rows (4 declared, 0 present here).
{:object, nil}
# List array header key[N]: ... (inline value on header line is also invalid)
String.match?(content, @list_array_regex) ->
{:object, nil}
# Normal key-value pair
String.match?(content, @field_pattern) ->
{:object, nil}
true ->
{:root_primitive, nil}
end
# Multiple lines -> object
true ->
{:object, nil}
end
end
# Parse root primitive value (single value without key)
defp parse_root_primitive([%{content: content}], _opts, metadata) do
unless valid_primitive?(content) do
raise DecodeError,
message: "Invalid TOON value: #{inspect(content)}",
input: content
end
{parse_value(content), metadata}
end
defp valid_primitive?(content) do
# Performance: Binary/String checks instead of regex for primitive validation
# Original: content in ~w(null true false) or
# String.starts_with?(content, "\"") or
# String.match?(content, ~r/^-?[0-9]+(\.[0-9]+)?([eE][+-]?[0-9]+)?$/) or
# not String.match?(content, ~r/[:,\n\r]/)
# Unquoted string: valid if it doesn't contain :, ,, \n, or \r
content in ~w(null true false) or
String.starts_with?(content, "\"") or
do_valid_number_format?(content) or
not do_contains_colon_comma_newline?(content)
end
# Performance: Binary scan for forbidden characters in unquoted strings
# Checks for: : , \n \r
defp do_contains_colon_comma_newline?(<<>>), do: false
defp do_contains_colon_comma_newline?(<<?:, _rest::binary>>), do: true
defp do_contains_colon_comma_newline?(<<?,, _rest::binary>>), do: true
defp do_contains_colon_comma_newline?(<<?\n, _rest::binary>>), do: true
defp do_contains_colon_comma_newline?(<<?\r, _rest::binary>>), do: true
defp do_contains_colon_comma_newline?(<<_byte, rest::binary>>),
do: do_contains_colon_comma_newline?(rest)
# Performance: Binary character range checks instead of regex for number format validation
# Matches: ^-?[0-9]+(\.[0-9]+)?([eE][+-]?[0-9]+)?$
defp do_valid_number_format?(<<>>), do: false
defp do_valid_number_format?(<<?-, rest::binary>>), do: do_valid_number_digits?(rest)
defp do_valid_number_format?(<<c, rest::binary>>) when c in ?0..?9,
do: do_valid_number_digits?(<<c, rest::binary>>)
defp do_valid_number_format?(_), do: false
defp do_valid_number_digits?(<<>>), do: true
defp do_valid_number_digits?(<<c, rest::binary>>) when c in ?0..?9,
do: do_valid_number_digits?(rest)
defp do_valid_number_digits?(<<?., rest::binary>>), do: do_valid_number_frac?(rest)
defp do_valid_number_digits?(<<c, rest::binary>>) when c == ?e or c == ?E,
do: do_valid_number_exp_sign?(rest)
defp do_valid_number_digits?(_), do: false
defp do_valid_number_frac?(<<>>), do: true
defp do_valid_number_frac?(<<c, rest::binary>>) when c in ?0..?9,
do: do_valid_number_frac?(rest)
defp do_valid_number_frac?(<<c, rest::binary>>) when c == ?e or c == ?E,
do: do_valid_number_exp_sign?(rest)
defp do_valid_number_frac?(_), do: false
defp do_valid_number_exp_sign?(<<>>), do: false
defp do_valid_number_exp_sign?(<<c, rest::binary>>) when c == ?+ or c == ?-,
do: do_valid_number_exp_digits?(rest)
defp do_valid_number_exp_sign?(<<c, rest::binary>>) when c in ?0..?9,
do: do_valid_number_exp_digits?(<<c, rest::binary>>)
defp do_valid_number_exp_sign?(_), do: false
defp do_valid_number_exp_digits?(<<>>), do: true
defp do_valid_number_exp_digits?(<<c, rest::binary>>) when c in ?0..?9,
do: do_valid_number_exp_digits?(rest)
defp do_valid_number_exp_digits?(_), do: false
# Parse root-level array
defp parse_root_array([%{content: header_line} = line_info | rest], opts, metadata) do
case Parser.parse_line(header_line) do
{:ok, [result], "", _, _, _} ->
# Handle inline array
case result do
{key, value} when is_list(value) ->
# Track metadata from parsed key-value
was_quoted = key_was_quoted?(header_line)
updated_metadata = add_key_to_metadata(key, was_quoted, metadata)
{value, updated_metadata}
_ ->
raise DecodeError, message: "Invalid root array format", input: header_line
end
{:error, _reason, _, _, _, _} ->
# Try parsing as tabular or list format
parse_complex_root_array(line_info, rest, opts, metadata)
end
end
defp parse_complex_root_array(%{content: header}, rest, opts, metadata) do
cond do
# Inline array with delimiter marker: [3\t]: ... or [3|]: ... or [3]: ...
String.starts_with?(header, "[") and String.contains?(header, "]: ") ->
{parse_root_inline_array(header, opts), metadata}
# Tabular array: [N]{fields}:
String.starts_with?(header, "[") and String.contains?(header, "]{") and
String.ends_with?(header, "}:") ->
{parse_tabular_array_data(header, rest, 0, opts), metadata}
# List array: [N]:
String.starts_with?(header, "[") and String.ends_with?(header, "]:") ->
{parse_list_array_items(rest, 0, opts), metadata}
true ->
raise DecodeError, message: "Invalid root array header", input: header
end
end
# Parse root inline array from header line
defp parse_root_inline_array(header, opts) do
# Extract everything after ": "
case String.split(header, ": ", parts: 2) do
[array_marker, values_str] ->
# Extract declared length from [N]
declared_length =
case Regex.run(@array_length_regex, array_marker) do
[_, length_str] -> String.to_integer(length_str)
_ -> nil
end
delimiter = extract_delimiter(array_marker)
values = parse_delimited_values(values_str, delimiter)
# Validate length if declared (strict mode only per TOON spec Section 14.1)
if Map.get(opts, :strict, true) && declared_length && length(values) != declared_length do
raise DecodeError,
message: "Array length mismatch: declared #{declared_length}, got #{length(values)}",
input: header
end
values
_ ->
raise DecodeError, message: "Invalid root inline array", input: header
end
end
# Helper function to build map with appropriate key type
# Performance: Use :maps.from_list/1 for string keys (faster C implementation than Map.new/1)
defp build_map_with_keys(entries, opts) do
case opts.keys do
:strings -> :maps.from_list(entries)
:atoms -> Map.new(entries, fn {k, v} -> {String.to_atom(k), v} end)
:atoms! -> Map.new(entries, fn {k, v} -> {String.to_existing_atom(k), v} end)
end
end
# Performance: Build map directly from parallel field/value lists without intermediate zip
defp build_map_from_fields_and_values(fields, values, opts) do
case opts.keys do
:strings ->
:maps.from_list(:lists.zip(fields, values))
:atoms ->
:maps.from_list(
:lists.zipwith(
fn k, v -> {String.to_atom(k), v} end,
fields,
values
)
)
:atoms! ->
:maps.from_list(
:lists.zipwith(
fn k, v -> {String.to_existing_atom(k), v} end,
fields,
values
)
)
end
end
defp put_key(map, key, value, opts) do
case opts.keys do
:strings -> Map.put(map, key, value)
:atoms -> Map.put(map, String.to_atom(key), value)
:atoms! -> Map.put(map, String.to_existing_atom(key), value)
end
end
defp empty_map(_opts), do: %{}
# Parse object from lines
defp parse_object_lines(lines, base_indent, opts, metadata) do
{entries, _remaining, updated_metadata} = parse_entries(lines, base_indent, opts, metadata)
{build_map_with_keys(entries, opts), updated_metadata}
end
# Parse entries at a specific indentation level
defp parse_entries([], _base_indent, _opts, metadata), do: {[], [], metadata}
defp parse_entries([line | rest] = lines, base_indent, opts, metadata) do
cond do
# Skip blank lines (only at root level or when not strict)
line.is_blank ->
# When strict, blank lines in nested content should be rejected by take_nested_lines
parse_entries(rest, base_indent, opts, metadata)
# Skip lines that are less indented (parent level)
line.indent < base_indent ->
{[], lines, metadata}
# Skip lines that are more indented (will be handled by parent)
line.indent > base_indent ->
{[], lines, metadata}
# Process line at current level
true ->
case parse_entry_line(line, rest, base_indent, opts, metadata) do
{:entry, key, value, remaining, updated_metadata} ->
{entries, final_remaining, final_metadata} =
parse_entries(remaining, base_indent, opts, updated_metadata)
{[{key, value} | entries], final_remaining, final_metadata}
{:skip, remaining, updated_metadata} ->
parse_entries(remaining, base_indent, opts, updated_metadata)
end
end
end
# Parse a single entry line
defp parse_entry_line(%{content: content} = line_info, rest, base_indent, opts, metadata) do
# Track if key was quoted by checking if line starts with quote
was_quoted = key_was_quoted?(content)
case Parser.parse_line(content) do
{:ok, [result], "", _, _, _} ->
case result do
{key, value} when is_list(value) ->
updated_meta = add_key_to_metadata(key, was_quoted, metadata)
# Check if this is an empty array with nested content (list or tabular format)
# Pattern like items[3]: with indented lines following
if value == [] and peek_next_indent(rest) > base_indent do
# This is a list/tabular array header, not an inline array
# Fall through to special line handling
case handle_special_line(line_info, rest, base_indent, opts, updated_meta) do
{:skip, _, updated_meta2} ->
# If special line handling doesn't work, treat as empty array
{:entry, key, [], rest, updated_meta2}
result ->
result
end
else
# Inline array - ALWAYS re-parse to respect leading zeros and other edge cases
# The Parser module may have already parsed numbers incorrectly
# Extract array marker from content to get delimiter
corrected_value =
case Regex.run(@array_header_with_colon_regex, content) do
[_, array_marker, length_str] ->
declared_length = String.to_integer(length_str)
delimiter = extract_delimiter(array_marker)
# Re-parse the values with correct delimiter
case String.split(content, ": ", parts: 2) do
[_, values_str] ->
values = parse_delimited_values(values_str, delimiter)
# Validate length (strict mode only per TOON spec Section 14.1)
if Map.get(opts, :strict, true) && length(values) != declared_length do
raise DecodeError,
message:
"Array length mismatch: declared #{declared_length}, got #{length(values)}",
input: content
end
values
_ ->
value
end
_ ->
value
end
{:entry, key, corrected_value, rest, updated_meta}
end
{key, value} ->
updated_meta = add_key_to_metadata(key, was_quoted, metadata)
case peek_next_indent(rest) do
indent when indent > base_indent ->
{nested_value, nested_meta} =
parse_nested_value(key, rest, base_indent, opts, updated_meta)
{remaining_lines, _} = skip_nested_lines(rest, base_indent)
{:entry, key, nested_value, remaining_lines, nested_meta}
_ ->
# FIX 1: When the raw value string is empty (e.g. "key: "), preserve
# the Parser's result (%{} from empty_kv) instead of calling
# parse_value(""), which would incorrectly return "".
corrected_value =
case String.split(content, ": ", parts: 2) do
[_, value_str] ->
trimmed_str = String.trim(value_str)
if trimmed_str == "", do: value, else: parse_value(trimmed_str)
_ ->
value
end
{:entry, key, corrected_value, rest, updated_meta}
end
end
{:ok, [parsed_result], rest_content, _, _, _} when rest_content != "" ->
case parsed_result do
{key, _partial_value} ->
updated_meta = add_key_to_metadata(key, was_quoted, metadata)
case String.split(content, ": ", parts: 2) do
[array_header, values_str] ->
# Re-parse as array if header contains [N]
case Regex.run(@array_header_with_values_regex, array_header) do
[_, length_str, delimiter_marker] ->
declared_length = String.to_integer(length_str)
delimiter = extract_delimiter("[#{delimiter_marker}]")
values = parse_delimited_values(values_str, delimiter)
# Validate length (strict mode only per TOON spec Section 14.1)
if Map.get(opts, :strict, true) && length(values) != declared_length do
raise DecodeError,
message:
"Array length mismatch: declared #{declared_length}, got #{length(values)}",
input: content
end
{:entry, key, values, rest, updated_meta}
nil ->
# Not an array line — original scalar fallback
full_value = parse_value(String.trim(values_str))
{:entry, key, full_value, rest, updated_meta}
end
_ ->
{:skip, rest, metadata}
end
_ ->
{:skip, rest, metadata}
end
{:ok, _, _, _, _, _} ->
# Unexpected parse result
{:skip, rest, metadata}
{:error, reason, _, _, _, _} ->
# Try to handle special cases like array headers
# If it still fails, raise an error
case handle_special_line(line_info, rest, base_indent, opts, metadata) do
{:skip, _, _meta} ->
raise DecodeError,
message: "Failed to parse line: #{reason}",
input: content
result ->
result
end
end
end
defp line_kind(content) do
cond do
String.match?(content, @tabular_header_pattern) ->
:tabular_array
String.match?(content, @list_header_pattern) ->
:list_array
String.ends_with?(content, @colon) and
not String.contains?(content, @space) ->
:nested_object
true ->
:unknown
end
end
# Handle special line formats (array headers, etc.)
defp handle_special_line(%{content: content} = line_info, rest, base_indent, opts, meta) do
case line_kind(content) do
:tabular_array -> parse_tabular_array_entry(line_info, rest, base_indent, opts, meta)
:list_array -> parse_list_array_entry(line_info, rest, base_indent, opts, meta)
:nested_object -> parse_nested_object_entry(content, rest, base_indent, opts, meta)
:unknown -> {:skip, rest, meta}
end
end
defp parse_tabular_array_entry(line_info, rest, base_indent, opts, metadata) do
{{key, array_value}, updated_meta} =
parse_tabular_array(line_info, rest, base_indent, opts, metadata)
{remaining, _} = skip_nested_lines(rest, base_indent)
{:entry, key, array_value, remaining, updated_meta}
end
defp parse_list_array_entry(line_info, rest, base_indent, opts, metadata) do
{{key, array_value}, updated_meta} =
parse_list_array(line_info, rest, base_indent, opts, metadata)
{remaining, _} = skip_nested_lines(rest, base_indent)
{:entry, key, array_value, remaining, updated_meta}
end
defp parse_nested_object_entry(content, rest, base_indent, opts, metadata) do
key = content |> String.trim_trailing(":") |> unquote_key()
was_quoted = key_was_quoted?(content)
updated_meta = add_key_to_metadata(key, was_quoted, metadata)
case peek_next_indent(rest) do
indent when indent > base_indent ->
{nested_value, nested_meta} = parse_nested_object(rest, base_indent, opts, updated_meta)
{remaining, _} = skip_nested_lines(rest, base_indent)
{:entry, key, nested_value, remaining, nested_meta}
_ ->
{:entry, key, %{}, rest, updated_meta}
end
end
# Parse nested value (object or array)
defp parse_nested_value(_key, lines, base_indent, opts, metadata) do
nested_lines = take_nested_lines(lines, base_indent)
# Use the actual indent of the first nested line, not base_indent + indent_size
# This allows non-multiple indentation when strict=false
actual_indent = get_first_content_indent(nested_lines)
parse_object_lines(nested_lines, actual_indent, opts, metadata)
end
# Parse nested object
defp parse_nested_object(lines, base_indent, opts, metadata) do
nested_lines = take_nested_lines(lines, base_indent)
# Use the actual indent of the first nested line, not base_indent + indent_size
actual_indent = get_first_content_indent(nested_lines)
parse_object_lines(nested_lines, actual_indent, opts, metadata)
end
# Parse tabular array
defp parse_tabular_array(%{content: header}, rest, base_indent, opts, metadata) do
case Regex.run(@tabular_array_header_regex, header) do
[_, raw_key, array_marker, fields_str] ->
key = unquote_key(raw_key)
was_quoted = key_was_quoted?(header)
updated_meta = add_key_to_metadata(key, was_quoted, metadata)
delimiter = extract_delimiter(array_marker)
fields = parse_fields(fields_str, delimiter)
# Extract declared length from array_marker
declared_length =
case Regex.run(@array_length_regex, array_marker) do
[_, len_str] -> String.to_integer(len_str)
nil -> nil
end
data_rows = take_nested_lines(rest, base_indent)
# Validate row count when a length was declared (always the case in TOON)
if declared_length != nil and length(data_rows) != declared_length do
raise DecodeError,
message:
"Tabular array row count mismatch: declared #{declared_length}, got #{length(data_rows)}",
input: header
end
array_data = parse_tabular_data_rows(data_rows, fields, delimiter, opts)
{{key, array_data}, updated_meta}
nil ->
raise DecodeError, message: "Invalid tabular array header", input: header
end
end
# Parse tabular array data rows
# Performance: Single-pass processing - filter blanks and parse in one traversal
defp parse_tabular_data_rows(lines, fields, delimiter, opts) do
field_count = length(fields)
Enum.reduce(lines, [], fn line, acc ->
if line.is_blank do
if opts.strict do
raise DecodeError,
message: "Blank lines are not allowed inside arrays in strict mode",
input: line.original
end
acc
else
values = parse_delimited_values(line.content, delimiter)
if length(values) != field_count do
raise DecodeError,
message: "Row value count mismatch: expected #{field_count}, got #{length(values)}",
input: line.content
end
# Build map directly from zipped fields and values
row_map = build_map_from_fields_and_values(fields, values, opts)
[row_map | acc]
end
end)
|> :lists.reverse()
end
# Parse tabular array data (for root arrays)
defp parse_tabular_array_data(header, rest, base_indent, opts) do
case Regex.run(@root_tabular_array_regex, header) do
[_, _full_length, length_str, delimiter_marker, fields_str] ->
declared_length = String.to_integer(length_str)
delimiter = extract_delimiter("[#{delimiter_marker}]")
fields = parse_fields(fields_str, delimiter)
data_rows = take_nested_lines(rest, base_indent)
# Validate row count
if length(data_rows) != declared_length do
raise DecodeError,
message:
"Tabular array row count mismatch: declared #{declared_length}, got #{length(data_rows)}",
input: header
end
parse_tabular_data_rows(data_rows, fields, delimiter, opts)
nil ->
raise DecodeError, message: "Invalid tabular array header", input: header
end
end
# Parse list array
defp parse_list_array(%{content: header}, rest, base_indent, opts, metadata) do
case Regex.run(@list_array_header_regex, header) do
[_, raw_key, array_marker] ->
length_str =
case Regex.run(@array_length_regex, array_marker) do
[_, len] -> len
nil -> "0"
end
declared_length = String.to_integer(length_str)
key = unquote_key(raw_key)
was_quoted = key_was_quoted?(header)
updated_meta = add_key_to_metadata(key, was_quoted, metadata)
# Extract delimiter from array marker and pass through opts
delimiter = extract_delimiter(array_marker)
opts_with_delimiter = Map.put(opts, :delimiter, delimiter)
items = parse_list_array_items(rest, base_indent, opts_with_delimiter)
# Validate length
# Validate item count (strict mode only per TOON spec Section 14.1)
if Map.get(opts, :strict, true) && length(items) != declared_length do
raise DecodeError,
message: "Array length mismatch: declared #{declared_length}, got #{length(items)}",
input: header
end
{{key, items}, updated_meta}
nil ->
raise DecodeError, message: "Invalid list array header", input: header
end
end
# Parse list array items
defp parse_list_array_items(lines, base_indent, opts) do
list_lines = take_nested_lines(lines, base_indent)
# Use the actual indent of the first list item, not base_indent + indent_size
actual_indent = get_first_content_indent(list_lines)
parse_list_items(list_lines, actual_indent, opts, [])
end
# Parse individual list items
defp parse_list_items([], _expected_indent, _opts, acc), do: :lists.reverse(acc)
defp parse_list_items([line | rest], expected_indent, opts, acc) do
cond do
# Skip blank lines (validate in strict mode if within array content)
line.is_blank ->
if opts.strict do
raise DecodeError,
message: "Blank lines are not allowed inside arrays in strict mode",
input: line.original
else
parse_list_items(rest, expected_indent, opts, acc)
end
# Inline array item with values on same line: - [N]: val1,val2
# (must have content after ": ", otherwise it's a list-format array header)
String.contains?(line.content, "]: ") and
String.starts_with?(String.trim_leading(line.content), "- [") ->
{item, remaining} = parse_inline_array_item(line, rest, expected_indent, opts)
parse_list_items(remaining, expected_indent, opts, [item | acc])
# List item marker (with space "- " or just "-")
String.starts_with?(String.trim_leading(line.content), "-") ->
{item, remaining} = parse_list_item(line, rest, expected_indent, opts)
parse_list_items(remaining, expected_indent, opts, [item | acc])
true ->
parse_list_items(rest, expected_indent, opts, acc)
end
end
# Pattern matching helpers for list item parsing
defp remove_list_marker(content) do
content
|> String.trim_leading()
|> String.replace_prefix("- ", "")
|> String.replace_prefix("-", "")
end
# Parse a single list item
defp parse_list_item(%{content: content} = line, rest, expected_indent, opts) do
trimmed = remove_list_marker(content)
route_list_item(trimmed, rest, line, expected_indent, opts)
end
defp route_list_item("", rest, _line, _expected_indent, _opts), do: {%{}, rest}
defp route_list_item(trimmed, rest, line, expected_indent, opts) do
cond do
String.trim(trimmed) == "" ->
{%{}, rest}
String.match?(trimmed, @inline_array_pattern) ->
parse_inline_array_from_line(trimmed, rest)
String.match?(trimmed, @list_array_header_pattern) ->
parse_nested_list_array(trimmed, rest, line, expected_indent, opts)
line_kind(trimmed) == :tabular_array ->
parse_list_item_with_array(trimmed, rest, line, expected_indent, opts, :tabular)
line_kind(trimmed) == :list_array ->
parse_list_item_with_array(trimmed, rest, line, expected_indent, opts, :list)
true ->
parse_list_item_normal(trimmed, rest, line, expected_indent, opts)
end
end
defp parse_list_item_normal(trimmed, rest, line, expected_indent, opts) do
delimiter = Map.get(opts, :delimiter, ",")
result = Parser.parse_line(trimmed)
case result do
{:ok, [result], "", _, _, _} ->
handle_complete_parse(result, trimmed, rest, line, expected_indent, opts)
{:ok, [{key, partial_value}], remaining_input, _, _, _}
when is_binary(remaining_input) and remaining_input != "" ->
handle_partial_parse(
key,
partial_value,
remaining_input,
delimiter,
trimmed,
rest,
line,
expected_indent,
opts
)
{:error, _, _, _, _, _} ->
handle_parse_error(trimmed, rest, expected_indent, opts)
end
end
defp handle_partial_parse(
key,
partial_value,
remaining_input,
delimiter,
trimmed,
rest,
line,
expected_indent,
opts
) do
if delimiter != "," and String.starts_with?(remaining_input, ",") do
full_value = parse_value(to_string(partial_value) <> remaining_input)
continuation_lines = take_item_lines(rest, expected_indent)
item_indent =
if length(continuation_lines) > 0,
do: continuation_lines |> Enum.map(& &1.indent) |> Enum.min(),
else: line.indent
adjusted_content = "#{key}: #{full_value}"
item_lines = [%{line | content: adjusted_content, indent: item_indent} | continuation_lines]
empty_metadata = %{quoted_keys: MapSet.new(), key_order: []}
{object, _} = parse_object_lines(item_lines, item_indent, opts, empty_metadata)
remaining = Enum.drop(rest, length(continuation_lines))
{object, remaining}
else
handle_complete_parse({key, partial_value}, trimmed, rest, line, expected_indent, opts)
end
end
# handle_complete_parse/6
#
# Builds a map object from a parsed list-item result plus its continuation lines.
#
# Design: use line.indent as the base for parse_object_lines so that standard
# TOON indentation-based nesting works correctly inside list items.
#
# - args: ← line.indent = 2
# device_id: val ← continuation at indent 4
#
# With base = line.indent = 2: peek_next_indent = 4 > 2 → nesting triggered
# → %{"args" => %{"device_id" => val}} ✓
#
# For non-empty valued first fields (e.g. "budget: 500 USD"), the
# continuation lines are siblings. We normalise all of them (including the
# first line) to cont_indent so they share one base level and none triggers
# spurious nesting via peek_next_indent.
defp handle_complete_parse(result, trimmed, rest, line, expected_indent, opts) do
case result do
{_key, value} ->
continuation_lines = take_item_lines(rest, expected_indent)
{item_lines, item_indent} =
if empty_list_item_value?(value) and continuation_lines != [] do
cont_indent = continuation_lines |> Enum.map(& &1.indent) |> Enum.min()
# Length of the list marker that was stripped from line.content
# ("- " → 2, "-" → 1). trimmed = remove_list_marker(line.content).
marker_len = byte_size(line.content) - byte_size(trimmed)
sibling_indent = line.indent + marker_len
if cont_indent > sibling_indent do
# Continuation lines are CHILDREN of this key (deeper than sibling
# level). Preserve line.indent so peek_next_indent detects nesting.
{[%{line | content: trimmed} | continuation_lines], line.indent}
else
# Continuation lines are SIBLINGS (same logical indent as this key).
# Normalise first-line indent to cont_indent so all fields share
# the same base level in parse_object_lines.
{[%{line | content: trimmed, indent: cont_indent} | continuation_lines],
cont_indent}
end
else
# Normal (non-empty) value: all continuation lines are siblings.
cont_indent =
if continuation_lines == [],
do: line.indent,
else: continuation_lines |> Enum.map(& &1.indent) |> Enum.min()
{[%{line | content: trimmed, indent: cont_indent} | continuation_lines], cont_indent}
end
empty_metadata = %{quoted_keys: MapSet.new(), key_order: []}
{object, _} = parse_object_lines(item_lines, item_indent, opts, empty_metadata)
remaining = Enum.drop(rest, length(continuation_lines))
{object, remaining}
value ->
{value, rest}
end
end
# Only %{} (the empty_kv placeholder) represents "no value supplied".
# nil (null literal) and "" (explicit quoted empty string) are real values
# and must NOT trigger the children/sibling disambiguation path.
defp empty_list_item_value?(value) when is_map(value) and map_size(value) == 0, do: true
defp empty_list_item_value?(_), do: false
defp handle_parse_error(trimmed, rest, expected_indent, opts) do
if String.ends_with?(trimmed, ":") and not String.contains?(trimmed, " ") do
next_indent = peek_next_indent(rest)
if next_indent > expected_indent do
parse_nested_key_with_content(trimmed, rest, next_indent, expected_indent, opts)
else
{parse_value(trimmed), rest}
end
else
# Strip trailing delimiter comma — it is separator noise, not value data.
value_str = String.trim_trailing(trimmed, ",")
{parse_value(value_str), rest}
end
end
# Helper to drop lines at a certain level
defp drop_lines_at_level(lines, min_indent) do
Enum.drop_while(lines, fn line -> !line.is_blank and line.indent >= min_indent end)
end
# Helper to build object with nested value
defp build_object_with_nested(key, nested_value, [], opts) do
put_key(empty_map(opts), key, nested_value, opts)
end
defp build_object_with_nested(key, nested_value, more_fields, opts) do
field_indent = more_fields |> Enum.map(& &1.indent) |> Enum.min()
empty_metadata = %{quoted_keys: MapSet.new(), key_order: []}
{remaining_object, _} = parse_object_lines(more_fields, field_indent, opts, empty_metadata)
put_key(remaining_object, key, nested_value, opts)
end
# Parse a key with nested content
defp parse_nested_key_with_content(trimmed, rest, next_indent, expected_indent, opts) do
key = trimmed |> String.trim_trailing(":") |> unquote_key()
# Take lines at the nested level
nested_lines = take_lines_at_level(rest, next_indent)
empty_metadata = %{quoted_keys: MapSet.new(), key_order: []}
{nested_value, _} = parse_object_lines(nested_lines, next_indent, opts, empty_metadata)
# Skip consumed nested lines
remaining_after_nested = drop_lines_at_level(rest, next_indent)
# Take remaining fields at the same level
more_fields = take_item_lines(remaining_after_nested, expected_indent)
object = build_object_with_nested(key, nested_value, more_fields, opts)
final_remaining =
if more_fields == [],
do: remaining_after_nested,
else: Enum.drop(remaining_after_nested, length(more_fields))
{object, final_remaining}
end
# Helper to get nested indent for list arrays
defp get_nested_indent([], expected_indent, opts),
do: expected_indent + Map.get(opts, :indent_size, 2)
defp get_nested_indent([%{indent: indent} | _], _expected_indent, _opts), do: indent
defp get_nested_indent(lines, _expected_indent, _opts),
do: lines |> Enum.map(& &1.indent) |> Enum.min()
# Helper to parse remaining fields in list item
defp parse_remaining_fields([], _opts), do: empty_map(nil)
defp parse_remaining_fields([%{indent: field_indent} | _] = fields, opts) do
empty_metadata = %{quoted_keys: MapSet.new(), key_order: []}
{result, _} = parse_object_lines(fields, field_indent, opts, empty_metadata)
result
end
defp parse_remaining_fields(fields, opts) do
field_indent = fields |> Enum.map(& &1.indent) |> Enum.min()
empty_metadata = %{quoted_keys: MapSet.new(), key_order: []}
{result, _} = parse_object_lines(fields, field_indent, opts, empty_metadata)
result
end
# Parse array from tabular header
defp parse_array_from_header(trimmed, rest, expected_indent, opts, :tabular) do
case Regex.run(@tabular_header_regex, trimmed) do
[_, raw_key, array_marker, fields_str] ->
key = unquote_key(raw_key)
delimiter = extract_delimiter(array_marker)
fields = parse_fields(fields_str, delimiter)
array_lines = take_array_data_lines(rest, expected_indent, opts)
{key, parse_tabular_data_rows(array_lines, fields, delimiter, opts)}
nil ->
raise DecodeError, message: "Invalid tabular array in list item", input: trimmed
end
end
# Parse array from list header
defp parse_array_from_header(trimmed, rest, expected_indent, opts, :list) do
case Regex.run(@list_array_regex, trimmed) do
[_, raw_key, _length_str] ->
key = unquote_key(raw_key)
array_lines = take_array_data_lines(rest, expected_indent, opts)
nested_indent = get_nested_indent(array_lines, expected_indent, opts)
{key, parse_list_items(array_lines, nested_indent, opts, [])}
nil ->
raise DecodeError, message: "Invalid list array in list item", input: trimmed
end
end
# Parse list item that starts with an array (tabular or list format)
defp parse_list_item_with_array(trimmed, rest, _line, expected_indent, opts, array_type) do
{key, array_value} = parse_array_from_header(trimmed, rest, expected_indent, opts, array_type)
{rest_after_array, _} = skip_array_data_lines(rest, expected_indent)
remaining_fields = take_item_lines(rest_after_array, expected_indent)
remaining_object = parse_remaining_fields(remaining_fields, opts)
object = put_key(remaining_object, key, array_value, opts)
{remaining, _} = skip_item_lines(rest, expected_indent)
{object, remaining}
end
# Take lines for array data (until we hit a non-array line at same level or higher)
defp take_array_data_lines(lines, base_indent, opts) do
# For tabular arrays: take lines at depth > base_indent that DON'T look like fields
# For list arrays: take all lines > base_indent (list items and their nested content)
# First, check if the first non-blank line starts with "-" (list array) or not (tabular)
first_content = Enum.find(lines, fn line -> !line.is_blank end)
is_list_array =
case first_content do
%{content: content} -> String.starts_with?(String.trim_leading(content), "-")
nil -> false
end
if is_list_array do
# For list arrays, we need to carefully track list items and their content
# Find the expected indent of list items (should be base_indent + indent_size)
list_item_indent =
case first_content do
%{indent: indent} -> indent
nil -> base_indent + Map.get(opts, :indent_size, 2)
end
# Take all list items and their nested content
# Stop at lines at list_item_indent level that don't start with "-"
Enum.take_while(lines, fn line ->
cond do
line.is_blank ->
true
line.indent > list_item_indent ->
# Nested content of list items
true
line.indent == list_item_indent ->
# At list item level: only continue if it's a list marker
String.starts_with?(String.trim_leading(line.content), "-")
true ->
false
end
end)
else
# Tabular array: take lines that don't look like fields
Enum.take_while(lines, fn line ->
cond do
line.is_blank ->
true
line.indent > base_indent ->
# Tabular array: take lines that don't look like "key: value"
not String.match?(line.content, @field_pattern)
true ->
false
end
end)
end
end
# Skip array data lines
defp skip_array_data_lines(lines, base_indent) do
# Use same logic as take_array_data_lines
first_content = Enum.find(lines, fn line -> !line.is_blank end)
is_list_array =
case first_content do
%{content: content} -> String.starts_with?(String.trim_leading(content), "-")
nil -> false
end
remaining =
if is_list_array do
# Use same logic as take: find list item indent and skip accordingly
list_item_indent =
case first_content do
%{indent: indent} -> indent
nil -> base_indent + 2
end
Enum.drop_while(lines, fn line ->
cond do
line.is_blank ->
true
line.indent > list_item_indent ->
true
line.indent == list_item_indent ->
String.starts_with?(String.trim_leading(line.content), "-")
true ->
false
end
end)
else
Enum.drop_while(lines, fn line ->
cond do
line.is_blank ->
true
line.indent > base_indent ->
not String.match?(line.content, @field_pattern)
true ->
false
end
end)
end
{remaining, length(lines) - length(remaining)}
end
# Parse inline array from a line like "[2]: a,b"
defp parse_inline_array_from_line(trimmed, rest) do
# Extract: [N], [N|], [N\t] format
case Regex.run(@inline_array_header_regex, trimmed) do
[_, array_marker, values_str] ->
delimiter = extract_delimiter(array_marker)
values =
if values_str == "" do
[]
else
parse_delimited_values(values_str, delimiter)
end
{values, rest}
nil ->
# Malformed, return as string
{trimmed, rest}
end
end
# Parse nested list-format array within a list item (e.g., "- [1]:" with nested items)
defp parse_nested_list_array(_trimmed, rest, _line, expected_indent, opts) do
array_lines = take_nested_lines(rest, expected_indent)
if Enum.empty?(array_lines) do
{[], rest}
else
nested_indent = get_first_content_indent(array_lines)
array_items = parse_list_items(array_lines, nested_indent, opts, [])
{rest_after_array, _} = skip_nested_lines(rest, expected_indent)
{array_items, rest_after_array}
end
end
# Parse inline array item in list
defp parse_inline_array_item(%{content: content}, rest, _expected_indent, _opts) do
trimmed = String.trim_leading(content) |> String.replace_prefix("- ", "")
# Use parse_inline_array_from_line directly since it handles [N]: format
parse_inline_array_from_line(trimmed, rest)
end
# Parse fields from tabular header - use active delimiter per TOON spec Section 6
# Performance: Use simple String.split when no quotes present (common case for simple identifiers)
defp parse_fields(fields_str, delimiter) do
if String.contains?(fields_str, @double_quote) do
# Quoted field names present - use full quote-aware splitting
split_respecting_quotes(fields_str, delimiter)
|> Enum.map(&String.trim/1)
|> Enum.map(&unquote_key/1)
else
# Simple identifiers - fast path with String.split
String.split(fields_str, delimiter, trim: true)
|> Enum.map(&String.trim/1)
end
end
# Extract delimiter from array marker like [2], [2|], [2\t]
# Performance: Binary pattern matching instead of String.contains?
defp extract_delimiter(array_marker) do
do_extract_delimiter(array_marker)
end
defp do_extract_delimiter(<<>>), do: @comma
defp do_extract_delimiter(<<?|, _rest::binary>>), do: @pipe
defp do_extract_delimiter(<<?\t, _rest::binary>>), do: @tab
defp do_extract_delimiter(<<_byte, rest::binary>>), do: do_extract_delimiter(rest)
# Parse delimited values from row
# Performance: Trim during split instead of separate Enum.map pass
defp parse_delimited_values(row_str, delimiter) do
actual_delimiter = detect_delimiter(row_str, delimiter)
split_and_parse_values(row_str, actual_delimiter)
end
# Performance: Split and parse in single pass, trimming during split
defp split_and_parse_values(str, delimiter) do
do_split_and_parse(str, delimiter, [], false, [])
end
defp do_split_and_parse("", _delimiter, current, _in_quote, acc) do
current_str =
current
|> :lists.reverse()
|> IO.iodata_to_binary()
|> do_trim_leading()
|> do_trim_trailing()
:lists.reverse([parse_value(current_str) | acc])
end
defp do_split_and_parse(<<"\\", char, rest::binary>>, delimiter, current, in_quote, acc) do
do_split_and_parse(rest, delimiter, [<<char>>, "\\" | current], in_quote, acc)
end
defp do_split_and_parse(<<"\"", rest::binary>>, delimiter, current, in_quote, acc) do
do_split_and_parse(rest, delimiter, ["\"" | current], not in_quote, acc)
end
defp do_split_and_parse(<<char, rest::binary>>, delimiter, current, false, acc)
when <<char>> == delimiter do
current_str =
current
|> :lists.reverse()
|> IO.iodata_to_binary()
|> do_trim_leading()
|> do_trim_trailing()
do_split_and_parse(rest, delimiter, [], false, [parse_value(current_str) | acc])
end
defp do_split_and_parse(<<char, rest::binary>>, delimiter, current, in_quote, acc) do
do_split_and_parse(rest, delimiter, [<<char>> | current], in_quote, acc)
end
# Extract the auto-detect logic so both places that call it stay readable:
# Performance: Single-pass binary scan instead of 2x String.contains?
defp detect_delimiter(row_str, @comma) do
if do_has_tab_no_comma?(row_str), do: @tab, else: @comma
end
defp detect_delimiter(_row_str, delimiter), do: delimiter
# Single-pass binary scan: returns true if string contains tab but no comma
defp do_has_tab_no_comma?(<<>>), do: false
defp do_has_tab_no_comma?(<<?\t, _rest::binary>>), do: true
defp do_has_tab_no_comma?(<<?,, _rest::binary>>), do: false
defp do_has_tab_no_comma?(<<_byte, rest::binary>>), do: do_has_tab_no_comma?(rest)
# Split a string by delimiter, but don't split inside quoted strings
defp split_respecting_quotes(str, delimiter) do
# Use a simple state machine approach with iolist building for O(n) performance
do_split_respecting_quotes(str, delimiter, [], false, [])
end
defp do_split_respecting_quotes("", _delimiter, current, _in_quote, acc) do
# Reverse current iolist and convert to string, then reverse acc
current_str = current |> :lists.reverse() |> IO.iodata_to_binary()
:lists.reverse([current_str | acc])
end
defp do_split_respecting_quotes(<<"\\", char, rest::binary>>, delimiter, current, in_quote, acc) do
# Escaped character - keep both backslash and char as iolist
do_split_respecting_quotes(rest, delimiter, [<<char>>, "\\" | current], in_quote, acc)
end
defp do_split_respecting_quotes(<<"\"", rest::binary>>, delimiter, current, in_quote, acc) do
# Toggle quote state - don't include the quote character in output
do_split_respecting_quotes(rest, delimiter, current, not in_quote, acc)
end
# NOTE: delimiter must be a single ASCII byte (`,`, `\t`, or `|`).
# Do not extend to multi-byte delimiters without replacing the byte-level
# pattern match below.
defp do_split_respecting_quotes(<<char, rest::binary>>, delimiter, current, false, acc)
when <<char>> == delimiter do
# Delimiter outside quotes - split here, convert current iolist to string
current_str = current |> Enum.reverse() |> IO.iodata_to_binary()
do_split_respecting_quotes(rest, delimiter, [], false, [current_str | acc])
end
defp do_split_respecting_quotes(<<char, rest::binary>>, delimiter, current, in_quote, acc) do
# Normal character - prepend to iolist
do_split_respecting_quotes(rest, delimiter, [<<char>> | current], in_quote, acc)
end
# Parse a single value - optimized with fast-path for already-clean strings
defp parse_value(str) do
# Fast-path: most tabular values are already clean (no leading/trailing whitespace)
# Check first and last byte before doing any trimming work
size = byte_size(str)
cond do
size == 0 ->
do_parse_value("")
:binary.first(str) in [?\s, ?\t] or :binary.last(str) in [?\s, ?\t] ->
# Whitespace detected - do full trim
str
|> do_trim_leading()
|> do_trim_trailing()
|> do_parse_value()
true ->
# Already clean - parse directly
do_parse_value(str)
end
end
# Fast-path binary trimming - avoids String.trim overhead
defp do_trim_leading(<<?\s, rest::binary>>), do: do_trim_leading(rest)
defp do_trim_leading(<<?\t, rest::binary>>), do: do_trim_leading(rest)
defp do_trim_leading(str), do: str
# Jason-style: binary scan for trailing whitespace instead of String.trim_trailing.
# Uses :binary.last/1 (BIF, very fast) to check the last byte, and
# binary_part/3 (O(1) sub-binary reference) to shrink the view.
# For the common case (no trailing whitespace), this is a single BIF call + return.
# For trailing whitespace, each iteration is O(1) — no intermediate allocations.
defp do_trim_trailing(str), do: do_trim_trailing(str, byte_size(str))
defp do_trim_trailing(_str, 0), do: <<>>
defp do_trim_trailing(str, size) do
case :binary.last(str) do
byte when byte == ?\s or byte == ?\t ->
do_trim_trailing(binary_part(str, 0, size - 1), size - 1)
_ ->
str
end
end
defp do_parse_value("null"), do: nil
defp do_parse_value("true"), do: true
defp do_parse_value("false"), do: false
defp do_parse_value("\"" <> _ = str), do: unquote_string(str)
defp do_parse_value(str), do: parse_number_or_string(str)
# Parse number or return as string
# Per TOON spec: numbers with leading zeros (except "0" itself) are treated as strings
# "0" and "-0" are valid numbers (both return 0)
defp parse_number_or_string("0"), do: 0
defp parse_number_or_string("-0"), do: 0
# Leading zeros make it a string (e.g., "05", "-007")
defp parse_number_or_string(<<"0", d, _rest::binary>> = str) when d in ?0..?9, do: str
defp parse_number_or_string(<<"-0", d, _rest::binary>> = str) when d in ?0..?9, do: str
# Try to parse as number, fall back to string
defp parse_number_or_string(str) do
case Float.parse(str) do
{num, ""} -> normalize_parsed_number(num, str)
_ -> str
end
end
# Convert parsed float to appropriate type based on original string format
defp normalize_parsed_number(num, str) do
if has_decimal_or_exponent?(str) do
normalize_decimal_number(num)
else
String.to_integer(str)
end
end
# Performance: Single-pass binary scan instead of 3x String.contains?
defp has_decimal_or_exponent?(<<>>), do: false
defp has_decimal_or_exponent?(<<?., _rest::binary>>), do: true
defp has_decimal_or_exponent?(<<?e, _rest::binary>>), do: true
defp has_decimal_or_exponent?(<<?E, _rest::binary>>), do: true
defp has_decimal_or_exponent?(<<_byte, rest::binary>>), do: has_decimal_or_exponent?(rest)
defp normalize_decimal_number(num) when num == trunc(num), do: trunc(num)
defp normalize_decimal_number(num), do: num
# Remove quotes from key
# Jason-style: binary pattern matching instead of String.slice
# Strips surrounding quotes in O(1) via binary_part — no allocation.
defp unquote_key(<<"\"", rest::binary>>) do
case do_strip_trailing_quote(rest) do
{:ok, inner} ->
unescape_string(inner)
:error ->
raise DecodeError, message: "Unterminated quoted key", input: <<"\"", rest::binary>>
end
end
defp unquote_key(key), do: key
# Strip trailing quote from a binary, returning {:ok, inner} or :error.
defp do_strip_trailing_quote(<<>>), do: :error
defp do_strip_trailing_quote(<<"\\">>), do: :error
defp do_strip_trailing_quote(<<"\"", _::binary>>), do: :error
defp do_strip_trailing_quote(binary) do
size = byte_size(binary)
<<last>> = binary_part(binary, size - 1, 1)
if last == ?" do
{:ok, binary_part(binary, 0, size - 1)}
else
:error
end
end
# Check if a key was originally quoted in the source line.
# Jason-style: binary pattern matching instead of String.trim_leading + String.starts_with?
# Eliminates 2 intermediate allocations.
defp key_was_quoted?(<<"\"", _rest::binary>>), do: true
defp key_was_quoted?(<<?\s, rest::binary>>), do: key_was_quoted?(rest)
defp key_was_quoted?(<<?\t, rest::binary>>), do: key_was_quoted?(rest)
defp key_was_quoted?(_), do: false
# Update metadata with a key, checking if it was quoted
defp add_key_to_metadata(key, was_quoted, metadata) do
updated =
if was_quoted,
do: %{metadata | quoted_keys: MapSet.put(metadata.quoted_keys, key)},
else: metadata
%{updated | key_order: [key | updated.key_order]}
end
# Remove quotes and unescape string
# Jason-style: binary_part instead of String.slice — O(1) sub-binary reference, no copy.
# Also avoids reconstructing the full quoted string for properly_quoted? by
# passing the already-matched parts directly.
defp unquote_string(<<"\"", rest::binary>>) do
if do_ends_with_unescaped_quote?(rest) do
# binary_part creates a sub-binary reference (O(1)), no allocation
inner = binary_part(rest, 0, byte_size(rest) - 1)
unescape_string(inner)
else
raise DecodeError, message: "Unterminated string", input: <<"\"", rest::binary>>
end
end
defp unquote_string(str), do: str
# Check if a binary (without leading quote) ends with an unescaped quote.
# Single-pass: scan from end, count trailing backslashes, check for quote.
defp do_ends_with_unescaped_quote?(<<>>), do: false
defp do_ends_with_unescaped_quote?(<<"\\">>), do: false
defp do_ends_with_unescaped_quote?(binary) do
size = byte_size(binary)
<<last>> = binary_part(binary, size - 1, 1)
case last do
?" ->
# Ends with quote — check if it's escaped
not escaped_quote_at_end?(binary)
?\\ ->
# Ends with backslash — not a valid closing
false
_ ->
false
end
end
# Check if the closing quote is escaped.
# Jason-style: single-pass binary scan from the end instead of
# String.slice → String.reverse → String.to_charlist → Enum.take_while → length
# (5 intermediate allocations → 0 allocations).
defp escaped_quote_at_end?(str) do
# Strip the trailing quote, then count consecutive backslashes from the end
str
|> binary_part(0, byte_size(str) - 1)
|> do_count_trailing_backslashes()
|> rem(2) == 1
end
# Count consecutive backslashes from the end of a binary.
# Single-pass scan — no intermediate allocations.
defp do_count_trailing_backslashes(<<>>), do: 0
defp do_count_trailing_backslashes(<<last>>) when last == ?\\, do: 1
defp do_count_trailing_backslashes(<<_last>>), do: 0
defp do_count_trailing_backslashes(<<byte, _rest::binary>>) when byte != ?\\, do: 0
defp do_count_trailing_backslashes(binary) do
size = byte_size(binary)
<<last>> = binary_part(binary, size - 1, 1)
if last == ?\\ do
1 + do_count_trailing_backslashes(binary_part(binary, 0, size - 1))
else
0
end
end
# Jason-style chunk-based unescaping with binary_part/3.
# Instead of wrapping every single byte in a list element (`[<<byte>> | acc]`),
# this uses two mutually recursive functions:
#
# do_unescape/4 — main loop, scans for backslash
# do_unescape_chunk/5 — accumulates consecutive safe bytes into a chunk
#
# When a backslash is found, the accumulated safe chunk is flushed via
# `binary_part(original, skip, len)` which is O(1) — it creates a sub-binary
# reference without copying. Only the escape replacement sequences are newly
# allocated. For strings with few escapes (the common case), this dramatically
# reduces the number of list elements and avoids per-byte allocation.
defp unescape_string(str), do: do_unescape(str, str, 0, [])
# Main loop: scan for backslash or end of input
defp do_unescape(<<>>, original, skip, acc),
do: finalize_unescape(acc, original, skip, 0)
defp do_unescape(<<"\\">>, _original, _skip, _acc),
do: raise(DecodeError, message: "Unterminated escape sequence", input: "\\")
defp do_unescape(<<"\\", char, rest::binary>>, original, skip, acc) do
# Flush any accumulated safe chunk, then append escape replacement
acc = flush_unescape_chunk(acc, original, skip, 0)
replacement = escape_char(char)
do_unescape(rest, original, skip + 2, [replacement | acc])
end
# Safe byte — enter chunk accumulation mode
defp do_unescape(<<_byte, rest::binary>>, original, skip, acc),
do: do_unescape_chunk(rest, original, skip, 1, acc)
# Chunk accumulation: count consecutive safe bytes without allocating
defp do_unescape_chunk(<<>>, original, skip, len, acc),
do: finalize_unescape([binary_part(original, skip, len) | acc], original, skip, 0)
defp do_unescape_chunk(<<"\\">>, _original, _skip, _len, _acc),
do: raise(DecodeError, message: "Unterminated escape sequence", input: "\\")
defp do_unescape_chunk(<<"\\", char, rest::binary>>, original, skip, len, acc) do
# Flush chunk via binary_part (O(1)), then append escape replacement
part = binary_part(original, skip, len)
replacement = escape_char(char)
do_unescape(rest, original, skip + len + 2, [replacement, part | acc])
end
defp do_unescape_chunk(<<_byte, rest::binary>>, original, skip, len, acc),
do: do_unescape_chunk(rest, original, skip, len + 1, acc)
# Flush a zero-length chunk (no-op) — avoids unnecessary binary_part call
@compile {:inline, flush_unescape_chunk: 4}
defp flush_unescape_chunk(acc, _original, _skip, 0), do: acc
defp flush_unescape_chunk(acc, original, skip, len),
do: [binary_part(original, skip, len) | acc]
# Final assembly: reverse the iodata list and convert to binary
@compile {:inline, finalize_unescape: 4}
defp finalize_unescape(acc, _original, _skip, 0),
do: acc |> :lists.reverse() |> IO.iodata_to_binary()
defp finalize_unescape(acc, original, skip, len),
do: [binary_part(original, skip, len) | acc] |> :lists.reverse() |> IO.iodata_to_binary()
# Escape character lookup — inlined for zero-overhead dispatch
defp escape_char(?\\), do: "\\"
defp escape_char(?"), do: "\""
defp escape_char(?n), do: "\n"
defp escape_char(?r), do: "\r"
defp escape_char(?t), do: "\t"
defp escape_char(char),
do:
raise(DecodeError, message: "Invalid escape sequence: \\#{<<char>>}", input: <<?\\, char>>)
# Peek at next line's indent (skip blank lines)
# Note: get_first_content_indent/1 shares the same logic but is kept separate
# for semantic clarity - both are inlined for performance
defp peek_next_indent([]), do: 0
defp peek_next_indent([%{is_blank: true} | rest]), do: peek_next_indent(rest)
defp peek_next_indent([%{indent: indent} | _]), do: indent
# Get the indent of the first non-blank line
defp get_first_content_indent([]), do: 0
defp get_first_content_indent([%{is_blank: true} | rest]), do: get_first_content_indent(rest)
defp get_first_content_indent([%{indent: indent} | _]), do: indent
# Take lines at or above a specific indent level (for nested content at exact level)
defp take_lines_at_level(lines, min_indent) do
Enum.take_while(lines, fn line ->
line.is_blank or line.indent >= min_indent
end)
end
# Take lines that are more indented than base
defp take_nested_lines(lines, base_indent) do
# We need to handle blank lines carefully:
# - Blank lines BETWEEN nested content should be included
# - Blank lines AFTER nested content should NOT be included
# We'll use a helper that tracks whether we're still in nested content
take_nested_lines_helper(lines, base_indent, false)
end
defp take_nested_lines_helper([], _base_indent, _seen_content), do: []
defp take_nested_lines_helper([line | rest], base_indent, seen_content) do
cond do
# Non-blank line that's more indented: include it and continue
!line.is_blank and line.indent > base_indent ->
[line | take_nested_lines_helper(rest, base_indent, true)]
# Non-blank line at base level or less: stop here
!line.is_blank ->
[]
# Blank line: only include if the next non-blank line is still nested
line.is_blank ->
next_content_indent = peek_next_indent(rest)
if next_content_indent > base_indent do
[line | take_nested_lines_helper(rest, base_indent, seen_content)]
else
# Next content is at base level or less, so stop here
[]
end
end
end
# Fixed – mirrors the logic of take_nested_lines_helper
defp skip_nested_lines(lines, base_indent) do
remaining = do_skip_nested(lines, base_indent)
{remaining, length(lines) - length(remaining)}
end
defp do_skip_nested([], _base_indent), do: []
defp do_skip_nested([line | rest] = all, base_indent) do
cond do
!line.is_blank and line.indent > base_indent ->
do_skip_nested(rest, base_indent)
!line.is_blank ->
all
line.is_blank ->
if peek_next_indent(rest) > base_indent do
do_skip_nested(rest, base_indent)
else
all
end
end
end
# Take lines for a list item (until next item marker at same level)
defp take_item_lines(lines, base_indent) do
Enum.take_while(lines, fn line ->
# Take lines that are MORE indented than base (continuation lines)
# Stop at next list item marker at the same level
if line.indent == base_indent do
not String.starts_with?(String.trim_leading(line.content), "- ")
else
line.indent > base_indent
end
end)
end
# Skip lines for a list item
defp skip_item_lines(lines, base_indent) do
remaining =
Enum.drop_while(lines, fn line ->
# Skip lines that are MORE indented than base (continuation lines)
# Stop at next list item marker at the same level
if line.indent == base_indent do
not String.starts_with?(String.trim_leading(line.content), "- ")
else
line.indent > base_indent
end
end)
{remaining, length(lines) - length(remaining)}
end
end