PMN (Portable Move Notation) implementation for Elixir.
This library provides parsing, formatting, and validation of PMN move strings as specified in the PMN Specification v1.0.0.
PMN encodes Moves (ordered sequences of Actions) using an operator-based syntax:
-movement to empty square+capture (infix: move+capture, prefix: static capture)~special movement with possible implicit effects*drop to empty square.drop with capture=in-place mutation...pass move
Security
This implementation is designed for untrusted input:
- Input length is validated first (DoS protection)
- Sub-tokens are validated by CELL and EPIN libraries
- No regex engine (no ReDoS vector)
- All error atoms are compile-time literals
Examples
iex> Sashite.Pmn.parse("e2-e4")
{:ok, %{form: :move_quiet, source: "e2", destination: "e4", actor: nil}}
iex> Sashite.Pmn.format(%{form: :move_quiet, source: "e2", destination: "e4", actor: nil})
{:ok, "e2-e4"}
iex> Sashite.Pmn.valid?("P*e5")
true
iex> Sashite.Pmn.parse("...")
{:ok, %{form: :pass}}
Summary
Functions
Formats a structured map into a PMN string.
Formats a structured map into a PMN string, raising ArgumentError on invalid input.
Returns the maximum string length for a PMN move (25).
Parses a PMN string into a structured map.
Parses a PMN string, raising ArgumentError on invalid input.
Returns true if the string is a valid PMN move.
Functions
Formats a structured map into a PMN string.
Returns {:ok, string} on success, or {:error, reason} on failure.
Examples
iex> Sashite.Pmn.format(%{form: :pass})
{:ok, "..."}
iex> Sashite.Pmn.format(%{form: :move_quiet, source: "e2", destination: "e4", actor: nil})
{:ok, "e2-e4"}
iex> Sashite.Pmn.format(%{form: :move_quiet, source: "e7", destination: "e8", actor: "Q"})
{:ok, "e7-e8=Q"}
iex> Sashite.Pmn.format(%{form: :move_capture, source: "b7", destination: "a8", actor: "Q", captured: "r"})
{:ok, "b7+a8=Q/r"}
iex> Sashite.Pmn.format(%{form: :move_special, source: "e1", destination: "g1", actor: nil, captured: nil})
{:ok, "e1~g1"}
iex> Sashite.Pmn.format(%{form: :static_capture, square: "d4", captured: nil})
{:ok, "+d4"}
iex> Sashite.Pmn.format(%{form: :static_capture, square: "d4", captured: "p"})
{:ok, "+d4/p"}
iex> Sashite.Pmn.format(%{form: :drop_quiet, piece: "P", destination: "e5", actor: nil})
{:ok, "P*e5"}
iex> Sashite.Pmn.format(%{form: :drop_quiet, piece: nil, destination: "e5", actor: nil})
{:ok, "*e5"}
iex> Sashite.Pmn.format(%{form: :drop_capture, piece: "L", destination: "b4", actor: nil, captured: nil})
{:ok, "L.b4"}
iex> Sashite.Pmn.format(%{form: :in_place_mutation, square: "e4", piece: "+P"})
{:ok, "e4=+P"}
iex> Sashite.Pmn.format(%{form: :unknown})
{:error, :invalid_form}
iex> Sashite.Pmn.format(%{form: :move_quiet})
{:error, :missing_field}Note: for capture forms, pass captured: nil when the captured piece is unchanged, and
captured: "EPIN" when it mutates. The formatter cannot infer this from the map alone —
the caller is responsible for supplying the correct value based on Position context.
Formats a structured map into a PMN string, raising ArgumentError on invalid input.
Examples
iex> Sashite.Pmn.format!(%{form: :move_quiet, source: "e2", destination: "e4", actor: nil})
"e2-e4"
iex> Sashite.Pmn.format!(%{form: :drop_quiet, piece: "P", destination: "e5", actor: nil})
"P*e5"
@spec max_string_length() :: pos_integer()
Returns the maximum string length for a PMN move (25).
The longest valid move is "iv256IV~iv256IV=+K^'/+K^'" (max CELL + operator +
max CELL + actor transformation + capture transformation).
Examples
iex> Sashite.Pmn.max_string_length()
25
Parses a PMN string into a structured map.
Returns {:ok, map} on success, or {:error, reason} on failure.
Examples
iex> Sashite.Pmn.parse("...")
{:ok, %{form: :pass}}
iex> Sashite.Pmn.parse("e2-e4")
{:ok, %{form: :move_quiet, source: "e2", destination: "e4", actor: nil}}
iex> Sashite.Pmn.parse("e7-e8=Q")
{:ok, %{form: :move_quiet, source: "e7", destination: "e8", actor: "Q"}}
iex> Sashite.Pmn.parse("d1+f3/n")
{:ok, %{form: :move_capture, source: "d1", destination: "f3", actor: nil, captured: "n"}}
iex> Sashite.Pmn.parse("b7+a8=Q/r")
{:ok, %{form: :move_capture, source: "b7", destination: "a8", actor: "Q", captured: "r"}}
iex> Sashite.Pmn.parse("e1~g1")
{:ok, %{form: :move_special, source: "e1", destination: "g1", actor: nil, captured: nil}}
iex> Sashite.Pmn.parse("+d4")
{:ok, %{form: :static_capture, square: "d4", captured: nil}}
iex> Sashite.Pmn.parse("+d4/p")
{:ok, %{form: :static_capture, square: "d4", captured: "p"}}
iex> Sashite.Pmn.parse("P*e5")
{:ok, %{form: :drop_quiet, piece: "P", destination: "e5", actor: nil}}
iex> Sashite.Pmn.parse("*e5")
{:ok, %{form: :drop_quiet, piece: nil, destination: "e5", actor: nil}}
iex> Sashite.Pmn.parse("L.b4")
{:ok, %{form: :drop_capture, piece: "L", destination: "b4", actor: nil, captured: nil}}
iex> Sashite.Pmn.parse("e4=+P")
{:ok, %{form: :in_place_mutation, square: "e4", piece: "+P"}}
iex> Sashite.Pmn.parse("")
{:error, :empty_input}
iex> Sashite.Pmn.parse("A1-e4")
{:error, :invalid_cell_token}Note: for capture forms, captured: nil means no mutation occurred — the captured piece
enters the hand unchanged. This is the only valid representation in that case. If the
captured piece's Piece Identity changes, captured holds the post-mutation EPIN and
the /<captured> suffix is mandatory. The parser accepts both; semantic conformance
(suffix present iff mutation) depends on Position context and is enforced at a higher level.
Parses a PMN string, raising ArgumentError on invalid input.
Examples
iex> Sashite.Pmn.parse!("e2-e4")
%{form: :move_quiet, source: "e2", destination: "e4", actor: nil}
iex> Sashite.Pmn.parse!("P*e5")
%{form: :drop_quiet, piece: "P", destination: "e5", actor: nil}
Returns true if the string is a valid PMN move.
This function never raises; it returns false for any invalid input,
including non-string values.
Examples
iex> Sashite.Pmn.valid?("e2-e4")
true
iex> Sashite.Pmn.valid?("...")
true
iex> Sashite.Pmn.valid?("P*e5")
true
iex> Sashite.Pmn.valid?("e7-e8=Q")
true
iex> Sashite.Pmn.valid?("b7+a8=Q/r")
true
iex> Sashite.Pmn.valid?("")
false
iex> Sashite.Pmn.valid?("A1-e4")
false
iex> Sashite.Pmn.valid?(nil)
false