Skip to main content

chessnut_move/protocol/
square.rs

1// Copyright 2026 Daymon Littrell-Reyes
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//   http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Canonical A1-to-H8 square coordinates and notation parsing.
16
17use core::fmt;
18use core::str::FromStr;
19
20use thiserror::Error;
21
22const BOARD_WIDTH: u8 = 8;
23
24/// Number of squares on a chessboard.
25pub const SQUARE_COUNT: usize = (BOARD_WIDTH as usize) * (BOARD_WIDTH as usize);
26
27/// A chessboard file from A through H.
28#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
29#[repr(u8)]
30pub enum File {
31  /// A-file.
32  A,
33
34  /// B-file.
35  B,
36
37  /// C-file.
38  C,
39
40  /// D-file.
41  D,
42
43  /// E-file.
44  E,
45
46  /// F-file.
47  F,
48
49  /// G-file.
50  G,
51
52  /// H-file.
53  H,
54}
55
56impl File {
57  /// Returns the zero-based file index from A through H.
58  ///
59  /// # Examples
60  ///
61  /// ```
62  /// use chessnut_move::protocol::File;
63  ///
64  /// assert_eq!(File::A.index(), 0);
65  /// assert_eq!(File::H.index(), 7);
66  /// ```
67  pub const fn index(self) -> u8 {
68    self as u8
69  }
70
71  const fn from_ascii(byte: u8) -> Option<Self> {
72    match byte {
73      b'a' | b'A' => Some(Self::A),
74      b'b' | b'B' => Some(Self::B),
75      b'c' | b'C' => Some(Self::C),
76      b'd' | b'D' => Some(Self::D),
77      b'e' | b'E' => Some(Self::E),
78      b'f' | b'F' => Some(Self::F),
79      b'g' | b'G' => Some(Self::G),
80      b'h' | b'H' => Some(Self::H),
81      _ => None,
82    }
83  }
84}
85
86impl fmt::Display for File {
87  fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
88    formatter.write_str(match self {
89      Self::A => "a",
90      Self::B => "b",
91      Self::C => "c",
92      Self::D => "d",
93      Self::E => "e",
94      Self::F => "f",
95      Self::G => "g",
96      Self::H => "h",
97    })
98  }
99}
100
101/// A chessboard rank from one through eight.
102#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
103#[repr(u8)]
104pub enum Rank {
105  /// First rank.
106  One,
107
108  /// Second rank.
109  Two,
110
111  /// Third rank.
112  Three,
113
114  /// Fourth rank.
115  Four,
116
117  /// Fifth rank.
118  Five,
119
120  /// Sixth rank.
121  Six,
122
123  /// Seventh rank.
124  Seven,
125
126  /// Eighth rank.
127  Eight,
128}
129
130impl Rank {
131  /// Returns the zero-based rank index from one through eight.
132  ///
133  /// # Examples
134  ///
135  /// ```
136  /// use chessnut_move::protocol::Rank;
137  ///
138  /// assert_eq!(Rank::One.index(), 0);
139  /// assert_eq!(Rank::Eight.index(), 7);
140  /// ```
141  pub const fn index(self) -> u8 {
142    self as u8
143  }
144
145  const fn from_ascii(byte: u8) -> Option<Self> {
146    match byte {
147      b'1' => Some(Self::One),
148      b'2' => Some(Self::Two),
149      b'3' => Some(Self::Three),
150      b'4' => Some(Self::Four),
151      b'5' => Some(Self::Five),
152      b'6' => Some(Self::Six),
153      b'7' => Some(Self::Seven),
154      b'8' => Some(Self::Eight),
155      _ => None,
156    }
157  }
158}
159
160impl fmt::Display for Rank {
161  fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
162    formatter.write_str(match self {
163      Self::One => "1",
164      Self::Two => "2",
165      Self::Three => "3",
166      Self::Four => "4",
167      Self::Five => "5",
168      Self::Six => "6",
169      Self::Seven => "7",
170      Self::Eight => "8",
171    })
172  }
173}
174
175/// A chessboard square stored in canonical A1-to-H8 order.
176#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
177#[repr(transparent)]
178pub struct Square(u8);
179
180impl Square {
181  /// This table gives internal wire codecs an infallible conversion from a
182  /// canonical index. Its length is tied to [SQUARE_COUNT] at compile time.
183  pub(crate) const ALL: [Self; SQUARE_COUNT] = {
184    let mut squares = [Self(0); SQUARE_COUNT];
185    let mut index = 0;
186    while index < SQUARE_COUNT {
187      squares[index] = Self(index as u8);
188      index += 1;
189    }
190    squares
191  };
192
193  /// Creates a square from its file and rank.
194  ///
195  /// # Examples
196  ///
197  /// ```
198  /// use chessnut_move::protocol::{File, Rank, Square};
199  ///
200  /// let square = Square::new(File::E, Rank::Four);
201  /// assert_eq!(square.to_string(), "e4");
202  /// ```
203  pub const fn new(file: File, rank: Rank) -> Self {
204    Self(rank.index() * BOARD_WIDTH + file.index())
205  }
206
207  /// Creates a square from a canonical zero-based index.
208  ///
209  /// Returns `None` when `index` is not less than [`SQUARE_COUNT`].
210  ///
211  /// # Examples
212  ///
213  /// ```
214  /// use chessnut_move::protocol::{File, Rank, Square};
215  ///
216  /// assert_eq!(Square::from_index(0), Some(Square::new(File::A, Rank::One)));
217  /// assert_eq!(Square::from_index(64), None);
218  /// ```
219  pub const fn from_index(index: usize) -> Option<Self> {
220    if index < SQUARE_COUNT {
221      Some(Self(index as u8))
222    } else {
223      None
224    }
225  }
226
227  /// Returns the canonical zero-based A1-to-H8 index.
228  ///
229  /// # Examples
230  ///
231  /// ```
232  /// use chessnut_move::protocol::{File, Rank, Square};
233  ///
234  /// assert_eq!(Square::new(File::A, Rank::One).index(), 0);
235  /// assert_eq!(Square::new(File::H, Rank::Eight).index(), 63);
236  /// ```
237  pub const fn index(self) -> usize {
238    self.0 as usize
239  }
240
241  /// Returns the square's file.
242  ///
243  /// # Examples
244  ///
245  /// ```
246  /// use chessnut_move::protocol::{File, Rank, Square};
247  ///
248  /// assert_eq!(Square::new(File::C, Rank::Six).file(), File::C);
249  /// ```
250  pub const fn file(self) -> File {
251    match self.0 % BOARD_WIDTH {
252      0 => File::A,
253      1 => File::B,
254      2 => File::C,
255      3 => File::D,
256      4 => File::E,
257      5 => File::F,
258      6 => File::G,
259      7 => File::H,
260      _ => unreachable!(),
261    }
262  }
263
264  /// Returns the square's rank.
265  ///
266  /// # Examples
267  ///
268  /// ```
269  /// use chessnut_move::protocol::{File, Rank, Square};
270  ///
271  /// assert_eq!(Square::new(File::C, Rank::Six).rank(), Rank::Six);
272  /// ```
273  pub const fn rank(self) -> Rank {
274    match self.0 / BOARD_WIDTH {
275      0 => Rank::One,
276      1 => Rank::Two,
277      2 => Rank::Three,
278      3 => Rank::Four,
279      4 => Rank::Five,
280      5 => Rank::Six,
281      6 => Rank::Seven,
282      7 => Rank::Eight,
283      _ => unreachable!(),
284    }
285  }
286}
287
288impl fmt::Display for Square {
289  fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
290    write!(formatter, "{}{}", self.file(), self.rank())
291  }
292}
293
294impl From<Square> for usize {
295  fn from(square: Square) -> Self {
296    square.index()
297  }
298}
299
300impl FromStr for Square {
301  type Err = ParseSquareError;
302
303  fn from_str(value: &str) -> Result<Self, Self::Err> {
304    let [file, rank] = value.as_bytes() else {
305      return Err(ParseSquareError);
306    };
307    let file = File::from_ascii(*file).ok_or(ParseSquareError)?;
308    let rank = Rank::from_ascii(*rank).ok_or(ParseSquareError)?;
309
310    Ok(Self::new(file, rank))
311  }
312}
313
314/// Reports that a string is not exactly one valid file and one valid rank.
315///
316/// # Examples
317///
318/// ```
319/// use chessnut_move::protocol::{File, Rank, Square};
320///
321/// assert_eq!("e4".parse::<Square>(), Ok(Square::new(File::E, Rank::Four)));
322/// assert!("e9".parse::<Square>().is_err());
323/// ```
324#[derive(Clone, Copy, Debug, PartialEq, Eq, Error)]
325#[error("invalid square notation")]
326pub struct ParseSquareError;
327
328#[cfg(test)]
329mod tests {
330  use super::*;
331
332  #[test]
333  fn squares_use_canonical_a1_to_h8_indexes() {
334    assert_eq!(Square::ALL.len(), SQUARE_COUNT);
335    assert_eq!(Square::ALL[0], Square::new(File::A, Rank::One));
336    assert_eq!(
337      Square::ALL[SQUARE_COUNT - 1],
338      Square::new(File::H, Rank::Eight)
339    );
340    assert_eq!(Square::new(File::A, Rank::One).index(), 0);
341    assert_eq!(Square::new(File::H, Rank::Eight).index(), 63);
342    assert_eq!(Square::from_index(64), None);
343  }
344
345  #[test]
346  fn square_notation_parses_case_insensitively() {
347    assert_eq!(
348      "H8".parse::<Square>(),
349      Ok(Square::new(File::H, Rank::Eight))
350    );
351    assert_eq!("a1".parse::<Square>(), Ok(Square::new(File::A, Rank::One)));
352  }
353
354  #[test]
355  fn invalid_square_notation_is_rejected() {
356    assert_eq!("i1".parse::<Square>(), Err(ParseSquareError));
357    assert_eq!("a9".parse::<Square>(), Err(ParseSquareError));
358    assert_eq!("a10".parse::<Square>(), Err(ParseSquareError));
359  }
360}