defmodule Sashite.Pmn.Parser do @moduledoc false # Secure parser for PMN (Portable Move Notation) strings. # # Designed for untrusted input: validates bounds first, identifies the # form by scanning for operator characters, then validates sub-tokens # by delegating to CELL and EPIN libraries. # # Parsing strategy — operator-first dispatch: # # 1. Check for "..." (pass) — exact match # 2. Scan for * or . — unambiguous drop operators (never in CELL/EPIN) # 3. Scan for -, +, ~, = — movement/mutation operators # 4. Split on operator, extract and validate sub-tokens # # The two-pass scan (drops first, then movements) resolves the ambiguity # where - and + can appear as EPIN state modifiers before * or . operators. # # Time complexity: O(n) where n ≤ 25 (bounded by max_string_length). # Space complexity: O(1) — binary slicing via binary_part (no copies). @max_string_length 25 # ── Public API ──────────────────────────────────────────────────────── @doc false @spec parse(String.t()) :: {:ok, map()} | {:error, atom()} def parse(<<>>), do: {:error, :empty_input} def parse(input) when is_binary(input) do if byte_size(input) > @max_string_length do {:error, :input_too_long} else identify_and_parse(input) end end def parse(_), do: {:error, :not_a_string} @doc false @spec parse!(String.t()) :: map() def parse!(input) do case parse(input) do {:ok, move} -> move {:error, reason} -> raise ArgumentError, Atom.to_string(reason) end end # ── Form identification ─────────────────────────────────────────────── # # Pass is matched exactly. Then drop operators (* and .) are scanned # first because - and + can appear as EPIN state modifiers before them. # Once drops are ruled out, -, +, ~, = are unambiguous. defp identify_and_parse("..."), do: {:ok, %{form: :pass}} defp identify_and_parse(input) do case scan_drop_operator(input, 0) do {?*, pos} -> parse_drop_quiet(input, pos) {?., pos} -> parse_drop_capture(input, pos) :none -> identify_non_drop(input) end end defp identify_non_drop(input) do case scan_movement_operator(input, 0) do {?-, pos} -> parse_move_quiet(input, pos) {?+, 0} -> parse_static_capture(input) {?+, pos} -> parse_move_capture(input, pos) {?~, pos} -> parse_move_special(input, pos) {?=, pos} -> parse_in_place_mutation(input, pos) :none -> {:error, :unrecognized_form} end end # ── Operator scanning ───────────────────────────────────────────────── # # Two separate scanners: drops first (*, .), then movements (-, +, ~, =). # Guards in clause heads for native BEAM dispatch. defp scan_drop_operator(<<>>, _pos), do: :none defp scan_drop_operator(<>, pos), do: {?*, pos} defp scan_drop_operator(<>, pos), do: {?., pos} defp scan_drop_operator(<<_, rest::binary>>, pos), do: scan_drop_operator(rest, pos + 1) defp scan_movement_operator(<<>>, _pos), do: :none defp scan_movement_operator(<>, pos), do: {?-, pos} defp scan_movement_operator(<>, pos), do: {?+, pos} defp scan_movement_operator(<>, pos), do: {?~, pos} defp scan_movement_operator(<>, pos), do: {?=, pos} defp scan_movement_operator(<<_, rest::binary>>, pos), do: scan_movement_operator(rest, pos + 1) # ── Form parsers: movements ────────────────────────────────────────── # src-dst[=actor] defp parse_move_quiet(input, op_pos) do source = binary_part(input, 0, op_pos) rest = binary_slice_after(input, op_pos) with {:ok, dst, actor} <- extract_dst_actor(rest), :ok <- validate_cell(source), :ok <- validate_cell(dst), :ok <- validate_optional_epin(actor) do {:ok, %{form: :move_quiet, source: source, destination: dst, actor: actor}} end end # src+dst[=actor][/captured] defp parse_move_capture(input, op_pos) do source = binary_part(input, 0, op_pos) rest = binary_slice_after(input, op_pos) with {:ok, dst, actor, captured} <- extract_dst_actor_captured(rest), :ok <- validate_cell(source), :ok <- validate_cell(dst), :ok <- validate_optional_epin(actor), :ok <- validate_optional_epin(captured) do {:ok, %{form: :move_capture, source: source, destination: dst, actor: actor, captured: captured}} end end # src~dst[=actor][/captured] defp parse_move_special(input, op_pos) do source = binary_part(input, 0, op_pos) rest = binary_slice_after(input, op_pos) with {:ok, dst, actor, captured} <- extract_dst_actor_captured(rest), :ok <- validate_cell(source), :ok <- validate_cell(dst), :ok <- validate_optional_epin(actor), :ok <- validate_optional_epin(captured) do {:ok, %{form: :move_special, source: source, destination: dst, actor: actor, captured: captured}} end end # ── Form parsers: static capture ───────────────────────────────────── # +square[/captured] defp parse_static_capture(input) do rest = binary_slice_after(input, 0) with {:ok, square, captured} <- extract_square_captured(rest), :ok <- validate_cell(square), :ok <- validate_optional_epin(captured) do {:ok, %{form: :static_capture, square: square, captured: captured}} end end # ── Form parsers: drops ────────────────────────────────────────────── # [piece]*dst[=actor] defp parse_drop_quiet(input, op_pos) do piece = if op_pos > 0, do: binary_part(input, 0, op_pos), else: nil rest = binary_slice_after(input, op_pos) with :ok <- validate_optional_epin(piece), {:ok, dst, actor} <- extract_dst_actor(rest), :ok <- validate_cell(dst), :ok <- validate_optional_epin(actor) do {:ok, %{form: :drop_quiet, piece: piece, destination: dst, actor: actor}} end end # [piece].dst[=actor][/captured] defp parse_drop_capture(input, op_pos) do piece = if op_pos > 0, do: binary_part(input, 0, op_pos), else: nil rest = binary_slice_after(input, op_pos) with :ok <- validate_optional_epin(piece), {:ok, dst, actor, captured} <- extract_dst_actor_captured(rest), :ok <- validate_cell(dst), :ok <- validate_optional_epin(actor), :ok <- validate_optional_epin(captured) do {:ok, %{form: :drop_capture, piece: piece, destination: dst, actor: actor, captured: captured}} end end # ── Form parsers: in-place mutation ────────────────────────────────── # square=piece defp parse_in_place_mutation(input, op_pos) do square = binary_part(input, 0, op_pos) piece = binary_slice_after(input, op_pos) with :ok <- validate_cell(square), :ok <- validate_epin(piece) do {:ok, %{form: :in_place_mutation, square: square, piece: piece}} end end # ── Suffix extraction ──────────────────────────────────────────────── # # Since = and / never appear in CELL tokens, they unambiguously # delimit suffixes. Split on / first (rightmost boundary), then # on = within the remaining prefix. # Extract dst[=actor] — for move_quiet and drop_quiet defp extract_dst_actor(rest) do case split_on(rest, <>) do {dst, actor} -> {:ok, dst, actor} :nomatch -> {:ok, rest, nil} end end # Extract dst[=actor][/captured] — for move_capture, move_special, drop_capture defp extract_dst_actor_captured(rest) do {before_slash, captured} = case split_on(rest, <>) do {before, after_slash} -> {before, after_slash} :nomatch -> {rest, nil} end case split_on(before_slash, <>) do {dst, actor} -> {:ok, dst, actor, captured} :nomatch -> {:ok, before_slash, nil, captured} end end # Extract square[/captured] — for static_capture defp extract_square_captured(rest) do case split_on(rest, <>) do {square, captured} -> {:ok, square, captured} :nomatch -> {:ok, rest, nil} end end # ── Validation helpers ─────────────────────────────────────────────── defp validate_cell(token) do if Sashite.Cell.valid?(token), do: :ok, else: {:error, :invalid_cell_token} end defp validate_epin(token) do if Sashite.Epin.valid?(token), do: :ok, else: {:error, :invalid_epin_token} end defp validate_optional_epin(nil), do: :ok defp validate_optional_epin(token), do: validate_epin(token) # ── Binary helpers ─────────────────────────────────────────────────── # Split binary on the first occurrence of a single-byte pattern. # Returns {before, after} or :nomatch. defp split_on(binary, pattern) do case :binary.match(binary, pattern) do {pos, 1} -> {binary_part(binary, 0, pos), binary_part(binary, pos + 1, byte_size(binary) - pos - 1)} :nomatch -> :nomatch end end # Slice binary after the byte at the given position (skipping it). defp binary_slice_after(binary, pos) do binary_part(binary, pos + 1, byte_size(binary) - pos - 1) end end