Current section
Files
Jump to
Current section
Files
lib/sashite/pmn.ex
defmodule Sashite.Pmn do
@moduledoc """
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](https://sashite.dev/specs/pmn/1.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}}
"""
alias Sashite.Pmn.{Formatter, Parser}
# ── Constants ─────────────────────────────────────────────────────────
@doc """
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
"""
@spec max_string_length() :: pos_integer()
def max_string_length, do: 25
# ── Parsing ───────────────────────────────────────────────────────────
@doc """
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.
"""
@spec parse(String.t()) :: {:ok, map()} | {:error, atom()}
defdelegate parse(string), to: Parser
@doc """
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}
"""
@spec parse!(String.t()) :: map()
defdelegate parse!(string), to: Parser
# ── Formatting ────────────────────────────────────────────────────────
@doc """
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.
"""
@spec format(map()) :: {:ok, String.t()} | {:error, atom()}
defdelegate format(move), to: Formatter
@doc """
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 format!(map()) :: String.t()
defdelegate format!(move), to: Formatter
# ── Validation ────────────────────────────────────────────────────────
@doc """
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
"""
@spec valid?(any()) :: boolean()
def valid?(string) when is_binary(string) do
match?({:ok, _}, Parser.parse(string))
end
def valid?(_), do: false
end