README.md

# pmn.ex

[![Hex.pm](https://img.shields.io/hexpm/v/sashite_pmn.svg)](https://hex.pm/packages/sashite_pmn)
[![Docs](https://img.shields.io/badge/hex-docs-blue.svg)](https://hexdocs.pm/sashite_pmn)
[![CI](https://github.com/sashite/pmn.ex/actions/workflows/elixir.yml/badge.svg?branch=main)](https://github.com/sashite/pmn.ex/actions)
[![License](https://img.shields.io/hexpm/l/sashite_pmn.svg)](https://github.com/sashite/pmn.ex/blob/main/LICENSE)

> **PMN** (Portable Move Notation) implementation for Elixir.

## Overview

This library implements the [PMN Specification v1.0.0](https://sashite.dev/specs/pmn/1.0.0/).

PMN is a human-readable string format for representing Moves (ordered sequences of Actions) in abstract strategy board games. It encodes Displacements between Locations, captures, drops, and Mutations of Piece Identity using an operator-based syntax:

| Form | Example | Meaning | Direct effect (Actions) |
|------|---------|---------|-------------------------|
| Pass | `...` | Pass move | 0 |
| Move (quiet) | `e2-e4` | Move to empty square | 1 |
| Move (capture) | `d1+f3` | Capture at destination then move actor | 2 |
| Static capture | `+d4` | Capture without actor movement | 1 |
| Special move | `e1~g1` | Move with rule-defined implicit effects | ≥ 1 |
| Drop (quiet) | `P*e5` | Drop from hand to empty square | 1 |
| Drop (capture) | `L.b4` | Capture at destination then drop actor | 2 |
| In-place mutation | `e4=+P` | Mutate piece on a square | 1 |

Optional transformation suffixes mutate pieces within a move: `=<piece>` transforms the actor, `/<piece>` transforms the captured piece.

### Implementation Constraints

| Constraint | Value | Rationale |
|------------|-------|-----------|
| Max string length | 25 | `"iv256IV~iv256IV=+K^'/+K^'"` (max for all components) |
| CELL tokens | max 7 bytes | Delegated to `sashite_cell` for validation |
| EPIN tokens | max 4 bytes | Delegated to `sashite_epin` for validation |

These constraints enable bounded memory usage and safe parsing of untrusted input.

## Installation

```elixir
# In your mix.exs
def deps do
  [
    {:sashite_pmn, "~> 1.0"}
  ]
end
```

## Dependencies

```elixir
{:sashite_cell, "~> 3.0"}  # Coordinate Encoding for Layered Locations
{:sashite_epin, "~> 1.2"}  # Extended Piece Identifier Notation
```

## Usage

### Parsing (String → Map)

Convert a PMN string into a structured map with validated components.

```elixir
# Pass move
{:ok, move} = Sashite.Pmn.parse("...")
move.form  # => :pass

# Simple move
{:ok, move} = Sashite.Pmn.parse("e2-e4")
move.form         # => :move_quiet
move.source       # => "e2"
move.destination  # => "e4"
move.actor        # => nil

# Move with actor transformation (promotion)
{:ok, move} = Sashite.Pmn.parse("e7-e8=Q")
move.form         # => :move_quiet
move.source       # => "e7"
move.destination  # => "e8"
move.actor        # => "Q"

# Capture
{:ok, move} = Sashite.Pmn.parse("d1+f3")
move.form         # => :move_capture
move.source       # => "d1"
move.destination  # => "f3"

# Capture with both transformations
{:ok, move} = Sashite.Pmn.parse("b7+a8=Q/R")
move.actor     # => "Q"
move.captured  # => "R"

# Special move (castling)
{:ok, move} = Sashite.Pmn.parse("e1~g1")
move.form  # => :move_special

# Static capture
{:ok, move} = Sashite.Pmn.parse("+d4")
move.form      # => :static_capture
move.square    # => "d4"

# Static capture with transformation
{:ok, move} = Sashite.Pmn.parse("+d4/p")
move.captured  # => "p"

# Drop with explicit piece
{:ok, move} = Sashite.Pmn.parse("P*e5")
move.form         # => :drop_quiet
move.piece        # => "P"
move.destination  # => "e5"

# Contextual drop (piece omitted)
{:ok, move} = Sashite.Pmn.parse("*e5")
move.piece  # => nil

# Drop with capture
{:ok, move} = Sashite.Pmn.parse("L.b4")
move.form         # => :drop_capture
move.piece        # => "L"
move.destination  # => "b4"

# In-place mutation
{:ok, move} = Sashite.Pmn.parse("e4=+P")
move.form    # => :in_place_mutation
move.square  # => "e4"
move.piece   # => "+P"

# Bang version (raises on error)
move = Sashite.Pmn.parse!("e2-e4")

# Invalid input returns error tuple
{:error, :empty_input} = Sashite.Pmn.parse("")
{:error, :invalid_cell_token} = Sashite.Pmn.parse("A1-e4")
{:error, :invalid_epin_token} = Sashite.Pmn.parse("e4=invalid")
```

### Formatting (Map → String)

Convert a structured map back into a PMN string.

```elixir
# Pass move
{:ok, "..."} = Sashite.Pmn.format(%{form: :pass})

# Simple move
{:ok, "e2-e4"} = Sashite.Pmn.format(%{form: :move_quiet, source: "e2",
  destination: "e4", actor: nil})

# Move with promotion
{:ok, "e7-e8=Q"} = Sashite.Pmn.format(%{form: :move_quiet, source: "e7",
  destination: "e8", actor: "Q"})

# Capture with both transformations
{:ok, "b7+a8=Q/R"} = Sashite.Pmn.format(%{form: :move_capture, source: "b7",
  destination: "a8", actor: "Q", captured: "R"})

# Special move
{:ok, "e1~g1"} = Sashite.Pmn.format(%{form: :move_special, source: "e1",
  destination: "g1", actor: nil, captured: nil})

# Static capture
{:ok, "+d4"} = Sashite.Pmn.format(%{form: :static_capture, square: "d4",
  captured: nil})

# Static capture with transformation
{:ok, "+d4/p"} = Sashite.Pmn.format(%{form: :static_capture, square: "d4",
  captured: "p"})

# Drop with piece
{:ok, "P*e5"} = Sashite.Pmn.format(%{form: :drop_quiet, piece: "P",
  destination: "e5", actor: nil})

# Contextual drop
{:ok, "*e5"} = Sashite.Pmn.format(%{form: :drop_quiet, piece: nil,
  destination: "e5", actor: nil})

# Drop with capture
{:ok, "L.b4"} = Sashite.Pmn.format(%{form: :drop_capture, piece: "L",
  destination: "b4", actor: nil, captured: nil})

# In-place mutation
{:ok, "e4=+P"} = Sashite.Pmn.format(%{form: :in_place_mutation, square: "e4",
  piece: "+P"})

# Bang version (raises on error)
"e2-e4" = Sashite.Pmn.format!(%{form: :move_quiet, source: "e2",
  destination: "e4", actor: nil})

# Invalid input returns error tuple
{:error, :invalid_form} = Sashite.Pmn.format(%{form: :unknown})
{:error, :missing_field} = Sashite.Pmn.format(%{form: :move_quiet})
```

### Validation

```elixir
# Boolean check (never raises)
Sashite.Pmn.valid?("e2-e4")     # => true
Sashite.Pmn.valid?("...")        # => true
Sashite.Pmn.valid?("P*e5")      # => true
Sashite.Pmn.valid?("e7-e8=Q")   # => true
Sashite.Pmn.valid?("b7+a8=Q/R") # => true
Sashite.Pmn.valid?("")           # => false
Sashite.Pmn.valid?("A1-e4")     # => false
Sashite.Pmn.valid?(nil)          # => false
```

### Round-trip

Parsing and formatting are inverse operations:

```elixir
# String → Map → String
"e7-e8=Q" = "e7-e8=Q" |> Sashite.Pmn.parse!() |> Sashite.Pmn.format!()

# Map → String → Map
move = %{form: :move_capture, source: "d1", destination: "f3",
         actor: nil, captured: nil}
^move = move |> Sashite.Pmn.format!() |> Sashite.Pmn.parse!()
```

## API Reference

### Constants

```elixir
Sashite.Pmn.max_string_length()  # => 25
```

### Parsing

```elixir
@spec Sashite.Pmn.parse(String.t()) :: {:ok, map()} | {:error, atom()}
@spec Sashite.Pmn.parse!(String.t()) :: map()
```

Parses a PMN string into a structured map. The bang version raises `ArgumentError` on invalid input.

### Formatting

```elixir
@spec Sashite.Pmn.format(map()) :: {:ok, String.t()} | {:error, atom()}
@spec Sashite.Pmn.format!(map()) :: String.t()
```

Formats a structured map into a PMN string. The bang version raises `ArgumentError` on invalid input.

### Validation

```elixir
@spec Sashite.Pmn.valid?(any()) :: boolean()
```

Reports whether the string is a valid PMN move. Never raises.

### Parsed move structures

Each form returns a map with a `:form` key and form-specific fields:

```elixir
# Pass
%{form: :pass}

# Move (quiet)
%{form: :move_quiet, source: String.t(), destination: String.t(),
  actor: String.t() | nil}

# Move (capture)
%{form: :move_capture, source: String.t(), destination: String.t(),
  actor: String.t() | nil, captured: String.t() | nil}

# Move (special)
%{form: :move_special, source: String.t(), destination: String.t(),
  actor: String.t() | nil, captured: String.t() | nil}

# Static capture
%{form: :static_capture, square: String.t(),
  captured: String.t() | nil}

# Drop (quiet)
%{form: :drop_quiet, piece: String.t() | nil, destination: String.t(),
  actor: String.t() | nil}

# Drop (capture)
%{form: :drop_capture, piece: String.t() | nil, destination: String.t(),
  actor: String.t() | nil, captured: String.t() | nil}

# In-place mutation
%{form: :in_place_mutation, square: String.t(), piece: String.t()}
```

CELL coordinates and EPIN identifiers are kept as validated strings. Consumers can decode them further using `Sashite.Cell.to_indices!/1` and `Sashite.Epin.parse!/1` as needed.

### Errors

Parsing errors are returned as atoms in `{:error, reason}` tuples:

| Atom | Cause |
|------|-------|
| `:not_a_string` | Input is not a binary |
| `:empty_input` | String length is 0 |
| `:input_too_long` | String exceeds 25 bytes |
| `:invalid_cell_token` | A square component is not a valid CELL coordinate |
| `:invalid_epin_token` | A piece component is not a valid EPIN identifier |
| `:unrecognized_form` | String does not match any PMN operator pattern |

Formatting errors:

| Atom | Cause |
|------|-------|
| `:not_a_map` | Input is not a map |
| `:invalid_form` | `:form` key missing or not a recognized form atom |
| `:missing_field` | A required field for the form is missing |
| `:invalid_field_value` | A field value has an unexpected type |

## Security

This library is designed for backend use where inputs may come from untrusted clients.

### Bounded resource consumption

All inputs are rejected at the byte level before any processing occurs:

- **Length check first**: Inputs longer than 25 bytes are rejected in O(1) before any parsing, preventing denial-of-service through oversized input.
- **No regex engine**: Parsing uses binary pattern matching and operator scanning, eliminating ReDoS as an attack vector.
- **Bounded sub-parsing**: CELL and EPIN tokens are validated through their respective libraries, each with their own bounded input checks (7 bytes and 4 bytes respectively).
- **No intermediate allocations**: The parser scans for operators, extracts slices, and validates them in place. No intermediate lists, atoms, or string copies are created beyond the final result map.

### Rejection guarantees

Any input that is not a valid PMN string is rejected with an `{:error, reason}` tuple. The rejection path does not raise exceptions (no backtrace capture cost), does not allocate atoms (all error atoms are compile-time literals), and executes in bounded time proportional to at most 25 bytes.

## Design Principles

- **Spec conformance**: Strict adherence to PMN v1.0.0, including all 8 forms and transformation suffix ordering
- **Compositional validation**: CELL and EPIN tokens are validated by their respective libraries, not reimplemented
- **Binary scanning on the hot path**: Operator detection scans raw bytes with guards, avoiding string-level operations
- **Minimal parsed output**: Results are plain maps with atom keys — no custom structs, no wrapper types, directly pattern-matchable
- **Elixir idioms**: `{:ok, _}` / `{:error, _}` tuples with bang variants, atom-based error reasons
- **Immutable by default**: All functions return new values

### Performance Architecture

PMN strings are short (1–25 bytes) with a fixed set of operator characters (`-`, `+`, `~`, `*`, `.`, `=`). The implementation exploits these constraints through two complementary strategies.

**Operator-first dispatch** — The parser identifies the form by scanning for operator characters in a single pass. Once the operator and its position are known, the string is split into validated sub-tokens (CELL coordinates and EPIN identifiers) without backtracking. This avoids the combinatorial explosion of trying each regex pattern sequentially.

**Delegated validation** — CELL and EPIN validation is delegated entirely to their respective libraries rather than reimplemented inline. This avoids code duplication and ensures that any performance improvements in those libraries automatically benefit PMN parsing.

**Direct iodata formatting** — The formatter assembles PMN strings from iodata lists in a single `IO.iodata_to_binary/1` call, avoiding repeated `<>` concatenation. Since all map values are already validated strings (or nil), no sub-token validation is needed on the formatting path.

## Related Specifications

- [Game Protocol](https://sashite.dev/game-protocol/) — Conceptual foundation
- [PMN Specification](https://sashite.dev/specs/pmn/1.0.0/) — Official specification
- [PMN Examples](https://sashite.dev/specs/pmn/1.0.0/examples/) — Usage examples
- [CELL Specification](https://sashite.dev/specs/cell/1.0.0/) — Coordinate encoding
- [EPIN Specification](https://sashite.dev/specs/epin/1.0.0/) — Piece identifiers

## License

Available as open source under the [Apache License 2.0](https://opensource.org/licenses/Apache-2.0).