Packages

PMN (Portable Move Notation) implementation for Elixir. Provides parsing and validation of protocol-level Moves as ordered sequences of Actions in abstract strategy board games.

Current section

Files

Jump to
sashite_pmn lib sashite pmn parser.ex
Raw

lib/sashite/pmn/parser.ex

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(<<?*, _::binary>>, pos), do: {?*, pos}
defp scan_drop_operator(<<?., _::binary>>, 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(<<?-, _::binary>>, pos), do: {?-, pos}
defp scan_movement_operator(<<?+, _::binary>>, pos), do: {?+, pos}
defp scan_movement_operator(<<?~, _::binary>>, pos), do: {?~, pos}
defp scan_movement_operator(<<?=, _::binary>>, 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