commit fe671b86825cf7e04bc622267b2a659c6fd2429c Author: clfreville2 Date: Thu Jan 26 17:21:14 2023 +0100 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b1a9812 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +target/ +Cargo.lock +dist/ + +.idea/ +.vscode/ diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..80d3a7e --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,5 @@ +[workspace] +members = [ + "board-shared", + "board-frontend", +] diff --git a/board-frontend/Cargo.toml b/board-frontend/Cargo.toml new file mode 100644 index 0000000..9b112be --- /dev/null +++ b/board-frontend/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "board-frontend" +version = "0.1.0" +edition = "2021" +description = "A web app built with Yew" +keywords = ["yew", "trunk"] +categories = ["gui", "wasm", "web-programming"] + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html +[dependencies] +yew = { version="0.20", features=["csr"] } +board-shared = { path = "../board-shared" } +gloo-dialogs = "0.1.1" diff --git a/board-frontend/index.html b/board-frontend/index.html new file mode 100644 index 0000000..f99eb25 --- /dev/null +++ b/board-frontend/index.html @@ -0,0 +1,9 @@ + + + + + Scrabble des chiffres + + + + diff --git a/board-frontend/index.scss b/board-frontend/index.scss new file mode 100644 index 0000000..3619942 --- /dev/null +++ b/board-frontend/index.scss @@ -0,0 +1,71 @@ +:root { + font-family: Inter, Avenir, Helvetica, Arial, sans-serif; + font-size: 16px; + line-height: 24px; + font-weight: 400; + + color-scheme: light dark; + color: rgba(255, 255, 255, 0.87); + background-color: #242424; + + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + -webkit-text-size-adjust: 100%; +} + +body { + margin: 0; + display: flex; + place-items: center; + min-width: 320px; + min-height: 100vh; +} + +main { + max-width: 1280px; + margin: 0 auto; + padding: 1rem; + text-align: center; +} + +.board { + display: flex; + flex-direction: column; +} + +.board-row { + display: flex; +} + +.cell, .tile { + width: 24px; + height: 24px; + border-radius: 4px; + display: flex; + justify-content: center; + flex-direction: column; +} + +.cell { + background-color: #888888; + margin: 4px; +} + +.button, .tile { + background-color: #535bf2; +} +.button:hover, .tile:hover { + background-color: #646cff; +} + +.button { + padding: 0.25rem 1rem; + border: 0; +} + +.hand { + display: flex; + justify-content: space-around; +} diff --git a/board-frontend/src/app.rs b/board-frontend/src/app.rs new file mode 100644 index 0000000..c0813fa --- /dev/null +++ b/board-frontend/src/app.rs @@ -0,0 +1,123 @@ +use crate::hand_view::HandView; +use crate::tile_view::PlacedTileView; +use board_shared::board::Board; +use board_shared::expr::is_valid_guess; +use board_shared::game::Game; +use board_shared::tile::Tile; +use gloo_dialogs::alert; +use yew::prelude::*; + +enum SelectedTile { + InHand(usize), + Equals, + None, +} + +#[derive(Properties, PartialEq)] +struct BoardViewProps { + board: Board, + on_click: Callback<(usize, usize)>, +} + +#[function_component(BoardView)] +fn board_view(BoardViewProps { board, on_click }: &BoardViewProps) -> Html { + html! { + + { (0..25).map(|x| html! { + + { (0..25).map(|y| html! { + + }).collect::() } + + }).collect::() } +
+ } +} + +#[function_component(App)] +pub fn app() -> Html { + let game = use_state(Game::default); + let current_game = use_state(Game::default); + + let selected_tile = use_state(|| SelectedTile::None); + let on_tile_click = { + let game = current_game.clone(); + let selected_tile = selected_tile.clone(); + Callback::from(move |(x, y)| { + if let SelectedTile::InHand(idx) = *selected_tile { + let mut in_hand = game.in_hand.clone(); + let tile = in_hand.tiles.remove(idx); + let mut board = game.board.clone(); + board.set(x, y, tile); + game.set(Game { board, in_hand }); + selected_tile.set(SelectedTile::None); + } else if let SelectedTile::Equals = *selected_tile { + let mut board = game.board.clone(); + board.set(x, y, Tile::Equals); + game.set(Game { + board, + in_hand: game.in_hand.clone(), + }); + selected_tile.set(SelectedTile::None); + } + }) + }; + let on_tile_select = { + let selected_tile = selected_tile.clone(); + Callback::from(move |idx| { + selected_tile.set(SelectedTile::InHand(idx)); + }) + }; + let on_equals_select = { + Callback::from(move |_| { + selected_tile.set(SelectedTile::Equals); + }) + }; + let on_continue_click = { + let current_game = current_game.clone(); + Callback::from(move |_| { + let diff = game.board.difference(¤t_game.board); + if let Some(true) = Board::is_contiguous(&diff) { + if let Ok(true) = is_valid_guess(¤t_game.board, &diff) { + alert("Valid move!"); + let mut in_hand = current_game.in_hand.clone(); + in_hand.complete(); + game.set(Game { + board: current_game.board.clone(), + in_hand: in_hand.clone() + }); + current_game.set(Game { + board: current_game.board.clone(), + in_hand, + }); + } else { + alert("Invalid move! (invalid expressions)"); + current_game.set(Game { + board: game.board.clone(), + in_hand: game.in_hand.clone(), + }); + } + } else { + if !diff.is_empty() { + alert("Invalid move! (not contiguous)"); + } + let mut in_hand = game.in_hand.clone(); + in_hand.complete(); + current_game.set(Game { + board: game.board.clone(), + in_hand, + }); + } + }) + }; + html! { +
+ + +
+ + +
+
+ } +} diff --git a/board-frontend/src/hand_view.rs b/board-frontend/src/hand_view.rs new file mode 100644 index 0000000..d071835 --- /dev/null +++ b/board-frontend/src/hand_view.rs @@ -0,0 +1,22 @@ +use crate::tile_view::TileView; +use board_shared::game::Hand; +use yew::prelude::*; +use yew::{html, Callback, Html}; + +#[derive(Properties, PartialEq)] +pub struct HandViewProps { + pub hand: Hand, + pub on_select: Callback, +} + +#[function_component(HandView)] +pub fn hand_view(HandViewProps { hand, on_select }: &HandViewProps) -> Html { + let on_select = on_select.clone(); + html! { +
+ { hand.tiles.iter().enumerate().map(|(i, tile)| html! { + + }).collect::() } +
+ } +} diff --git a/board-frontend/src/main.rs b/board-frontend/src/main.rs new file mode 100644 index 0000000..8a3496e --- /dev/null +++ b/board-frontend/src/main.rs @@ -0,0 +1,9 @@ +mod app; +mod hand_view; +mod tile_view; + +use app::App; + +fn main() { + yew::Renderer::::new().render(); +} diff --git a/board-frontend/src/tile_view.rs b/board-frontend/src/tile_view.rs new file mode 100644 index 0000000..1992c5e --- /dev/null +++ b/board-frontend/src/tile_view.rs @@ -0,0 +1,59 @@ +use board_shared::tile::Tile; +use yew::prelude::*; +use yew::{html, Callback, Html}; + +#[derive(Properties, PartialEq)] +pub struct PlacedTileViewProps { + pub x: usize, + pub y: usize, + pub tile: Option, + pub on_click: Callback<(usize, usize)>, +} + +#[function_component(PlacedTileView)] +pub fn placed_tile_view( + PlacedTileViewProps { + x, + y, + tile, + on_click, + }: &PlacedTileViewProps, +) -> Html { + let x = *x; + let y = *y; + let on_select = { + let on_click = on_click.clone(); + Callback::from(move |_| on_click.emit((x, y))) + }; + html! { + { tile.map(|tile| { + html! { tile } + }).unwrap_or_else(|| { + html! { "" } + })} + } +} + +#[derive(Properties, PartialEq)] +pub struct TileViewProps { + pub tile: Tile, + pub on_select: Callback, + pub idx: usize, +} + +#[function_component(TileView)] +pub fn tile_view( + TileViewProps { + tile, + on_select, + idx, + }: &TileViewProps, +) -> Html { + let on_select = on_select.clone(); + let idx = *idx; + html! { +
{ tile }
+ } +} diff --git a/board-shared/Cargo.toml b/board-shared/Cargo.toml new file mode 100644 index 0000000..a7885eb --- /dev/null +++ b/board-shared/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "board-shared" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] diff --git a/board-shared/src/board.rs b/board-shared/src/board.rs new file mode 100644 index 0000000..c76fbd3 --- /dev/null +++ b/board-shared/src/board.rs @@ -0,0 +1,86 @@ +use crate::tile::Tile; + +const BOARD_SIZE: usize = 25; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Board { + tiles: [Option; BOARD_SIZE * BOARD_SIZE], +} + +impl Board { + pub fn get(&self, x: usize, y: usize) -> Option { + self.tiles[y * BOARD_SIZE + x] + } + + pub fn set(&mut self, x: usize, y: usize, tile: Tile) { + self.tiles[y * BOARD_SIZE + x] = Some(tile); + } + + pub fn difference(&self, other: &Board) -> Vec<(usize, usize)> { + let mut diff = Vec::new(); + for x in 0..BOARD_SIZE { + for y in 0..BOARD_SIZE { + if self.get(x, y) != other.get(x, y) { + diff.push((x, y)); + } + } + } + diff + } + + pub fn is_contiguous(positions: &[(usize, usize)]) -> Option { + let mut it = positions.iter(); + let first = *it.next()?; + let mut second = *it.next()?; + if first.0 == second.0 { + // Vertical + for &(x, y) in it { + if x != first.0 || y != second.1 + 1 { + return Some(false); + } + second = (x, y); + } + Some(true) + } else if first.1 == second.1 { + // Horizontal + for &(x, y) in it { + if y != first.1 || x != second.0 + 1 { + return Some(false); + } + second = (x, y); + } + Some(true) + } else { + Some(false) + } + } +} + +impl Default for Board { + fn default() -> Self { + Self { + tiles: [None; BOARD_SIZE * BOARD_SIZE], + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_is_contiguous() { + assert_eq!(Board::is_contiguous(&[]), None); + assert_eq!(Board::is_contiguous(&[(0, 0)]), None); + assert_eq!(Board::is_contiguous(&[(0, 0), (0, 1), (0, 2)]), Some(true)); + assert_eq!( + Board::is_contiguous(&[(1, 0), (2, 0), (3, 0), (4, 0)]), + Some(true) + ); + assert_eq!(Board::is_contiguous(&[(0, 0), (0, 1), (1, 3)]), Some(false)); + assert_eq!( + Board::is_contiguous(&[(0, 0), (0, 1), (0, 2), (1, 2)]), + Some(false) + ); + } +} diff --git a/board-shared/src/expr.rs b/board-shared/src/expr.rs new file mode 100644 index 0000000..61e9bde --- /dev/null +++ b/board-shared/src/expr.rs @@ -0,0 +1,91 @@ +use crate::board::Board; +use crate::lexer::lexer; +use crate::parser; +use crate::parser::{Expression, Expressions}; +use crate::tile::{Operator, Tile}; + +pub fn calculate(expr: &Expression) -> f64 { + match expr { + Expression::Digit(value) => *value as f64, + Expression::Parentheses(expr) => calculate(expr), + Expression::Binary(operator, left, right) => { + let left = calculate(left); + let right = calculate(right); + match operator { + Operator::Add => left + right, + Operator::Subtract => left - right, + Operator::Multiply => left * right, + Operator::Divide => left / right, + } + } + } +} + +pub fn are_valid_expressions(expr: &Expressions) -> bool { + let mut res: Option = None; + for expr in expr { + let value = calculate(expr); + if let Some(res) = res { + if res != value { + return false; + } + } else { + res = Some(value); + } + } + res.is_some() +} + +pub fn is_valid_guess(board: &Board, positions: &[(usize, usize)]) -> Result { + let tiles = positions + .iter() + .map(|&(x, y)| board.get(x, y)) + .collect::>>() + .ok_or(())?; + + let tokens = lexer(&tiles)?; + let expressions = parser::parse(&tokens)?; + Ok(are_valid_expressions(&expressions)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_calculate() { + let expr = Expression::Binary( + Operator::Add, + Box::new(Expression::Digit(1)), + Box::new(Expression::Digit(2)), + ); + assert_eq!(calculate(&expr), 3.0); + } + + #[test] + fn test_are_valid_expressions() { + let expr = vec![ + Expression::Binary( + Operator::Add, + Box::new(Expression::Digit(3)), + Box::new(Expression::Digit(4)), + ), + Expression::Binary( + Operator::Add, + Box::new(Expression::Digit(6)), + Box::new(Expression::Digit(1)), + ), + ]; + assert!(are_valid_expressions(&expr)); + + let expr = vec![ + Expression::Digit(9), + Expression::Binary( + Operator::Add, + Box::new(Expression::Digit(7)), + Box::new(Expression::Digit(1)), + ), + ]; + assert!(!are_valid_expressions(&expr)); + } +} diff --git a/board-shared/src/game.rs b/board-shared/src/game.rs new file mode 100644 index 0000000..cb18f54 --- /dev/null +++ b/board-shared/src/game.rs @@ -0,0 +1,42 @@ +use crate::board::Board; +use crate::tile::{Digit, Operator, Tile}; + +#[derive(Default, Debug, Clone, PartialEq)] +pub struct Game { + pub board: Board, + pub in_hand: Hand, +} + +#[derive(Default, Debug, Clone, PartialEq)] +pub struct Hand { + pub tiles: Vec, +} + +impl Hand { + pub fn count_missing_operators(&self) -> usize { + 4usize.saturating_sub( + self.tiles + .iter() + .filter(|tile| matches!(tile, Tile::Operator(_))) + .count(), + ) + } + + pub fn count_missing_numbers(&self) -> usize { + 8usize.saturating_sub( + self.tiles + .iter() + .filter(|tile| matches!(tile, Tile::Digit(_))) + .count(), + ) + } + + pub fn complete(&mut self) { + for _ in 0..self.count_missing_operators() { + self.tiles.push(Tile::Operator(Operator::Add)); + } + for n in 0..self.count_missing_numbers() { + self.tiles.push(Tile::Digit(Digit::new((n % 10) as u8))); + } + } +} diff --git a/board-shared/src/lexer.rs b/board-shared/src/lexer.rs new file mode 100644 index 0000000..290c36b --- /dev/null +++ b/board-shared/src/lexer.rs @@ -0,0 +1,145 @@ +use crate::tile::{Operator, Tile}; +use std::fmt; + +#[derive(Debug, PartialEq, Clone)] +pub enum Token { + NumberLiteral(u64), + Operator(Operator), + LeftParen, + RightParen, + Equals, +} + +impl fmt::Display for Token { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Token::NumberLiteral(value) => write!(f, "{value}"), + Token::Operator(operator) => write!(f, "{operator}"), + Token::LeftParen => write!(f, "("), + Token::RightParen => write!(f, ")"), + Token::Equals => write!(f, "="), + } + } +} + +/// Tokenize a sequence of tiles into tokens. +pub fn lexer(input: &[Tile]) -> Result, ()> { + let mut result = Vec::new(); + + let mut it = input.iter().peekable(); + while let Some(&c) = it.peek() { + match c { + Tile::Digit(digit) => { + let mut has_right_parenthesis = digit.has_right_parenthesis; + if digit.has_left_parenthesis { + result.push(Token::LeftParen); + } + let mut value = digit.value as u64; + it.next(); + while let Some(&Tile::Digit(digit)) = it.peek() { + if digit.has_left_parenthesis { + break; + } + value = value * 10 + digit.value as u64; + it.next(); + if digit.has_right_parenthesis { + has_right_parenthesis = true; + break; + } + } + result.push(Token::NumberLiteral(value)); + if has_right_parenthesis { + result.push(Token::RightParen); + } + } + Tile::Operator(operator) => { + result.push(Token::Operator(*operator)); + it.next(); + } + Tile::Equals => { + result.push(Token::Equals); + it.next(); + } + } + } + Ok(result) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_lex() { + let input = vec![ + Tile::Digit(Digit::new(1)), + Tile::Digit(Digit::new(2)), + Tile::Digit(Digit::new(3)), + Tile::Operator(Operator::Add), + Tile::Digit(Digit::new(4)), + Tile::Digit(Digit::new(5)), + Tile::Digit(Digit::new(6)), + Tile::Equals, + ]; + let expected = vec![ + Token::NumberLiteral(123), + Token::Operator(Operator::Add), + Token::NumberLiteral(456), + Token::Equals, + ]; + assert_eq!(lexer(&input).unwrap(), expected); + } + + #[test] + fn test_lex_parentheses() { + let input = vec![ + Tile::Digit(Digit::new(1)), + Tile::Operator(Operator::Subtract), + Tile::Digit(Digit { + value: 2, + has_left_parenthesis: true, + has_right_parenthesis: false, + }), + Tile::Operator(Operator::Add), + Tile::Digit(Digit { + value: 3, + has_left_parenthesis: false, + has_right_parenthesis: true, + }), + ]; + let expected = vec![ + Token::NumberLiteral(1), + Token::Operator(Operator::Subtract), + Token::LeftParen, + Token::NumberLiteral(2), + Token::Operator(Operator::Add), + Token::NumberLiteral(3), + Token::RightParen, + ]; + assert_eq!(lexer(&input).unwrap(), expected); + } + + #[test] + fn test_lex_parentheses_long() { + let input = vec![ + Tile::Digit(Digit { + value: 1, + has_left_parenthesis: true, + has_right_parenthesis: false, + }), + Tile::Digit(Digit::new(2)), + Tile::Digit(Digit::new(3)), + Tile::Digit(Digit { + value: 4, + has_left_parenthesis: false, + has_right_parenthesis: true, + }), + ]; + let expected = vec![ + Token::LeftParen, + Token::NumberLiteral(1234), + Token::RightParen, + ]; + assert_eq!(lexer(&input).unwrap(), expected); + } +} diff --git a/board-shared/src/lib.rs b/board-shared/src/lib.rs new file mode 100644 index 0000000..a85819d --- /dev/null +++ b/board-shared/src/lib.rs @@ -0,0 +1,6 @@ +pub mod board; +pub mod expr; +pub mod game; +mod lexer; +mod parser; +pub mod tile; diff --git a/board-shared/src/parser.rs b/board-shared/src/parser.rs new file mode 100644 index 0000000..b76395a --- /dev/null +++ b/board-shared/src/parser.rs @@ -0,0 +1,140 @@ +use crate::lexer::Token; +use crate::tile::Operator; +use std::iter::Peekable; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Expression { + Digit(u64), + Parentheses(Box), + Binary(Operator, Box, Box), +} + +pub type Expressions = Vec; + +pub fn parse(tokens: &[Token]) -> Result { + let mut tokens = tokens.iter().peekable(); + let mut expressions = Vec::new(); + while tokens.peek().is_some() { + expressions.push(parse_expression(&mut tokens)?); + tokens.next(); + } + Ok(expressions) +} + +fn parse_expression<'a>( + tokens: &mut Peekable>, +) -> Result { + let mut left = parse_term(tokens)?; + while let Some(Token::Operator(operator)) = tokens.peek() { + let operator = *operator; + tokens.next(); + let right = parse_term(tokens)?; + left = Expression::Binary(operator, Box::new(left), Box::new(right)); + } + Ok(left) +} + +fn parse_term<'a>( + tokens: &mut Peekable>, +) -> Result { + let mut left = parse_factor(tokens)?; + while let Some(Token::Operator(operator)) = tokens.peek() { + let operator = *operator; + tokens.next(); + let right = parse_factor(tokens)?; + left = Expression::Binary(operator, Box::new(left), Box::new(right)); + } + Ok(left) +} + +fn parse_factor<'a>( + tokens: &mut Peekable>, +) -> Result { + match tokens.next() { + Some(Token::NumberLiteral(value)) => Ok(Expression::Digit(*value)), + Some(Token::LeftParen) => { + let expression = parse_expression(tokens)?; + if let Some(Token::RightParen) = tokens.next() { + Ok(Expression::Parentheses(Box::new(expression))) + } else { + Err(()) + } + } + _ => Err(()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse() { + let tokens = vec![ + Token::NumberLiteral(1), + Token::Operator(Operator::Add), + Token::NumberLiteral(2), + Token::Operator(Operator::Multiply), + Token::NumberLiteral(3), + ]; + let expression = parse(&tokens).unwrap(); + assert_eq!( + expression, + vec![Expression::Binary( + Operator::Multiply, + Box::new(Expression::Binary( + Operator::Add, + Box::new(Expression::Digit(1)), + Box::new(Expression::Digit(2)), + )), + Box::new(Expression::Digit(3)), + )], + ); + } + + #[test] + fn test_parse_parentheses() { + let tokens = vec![ + Token::LeftParen, + Token::NumberLiteral(1), + Token::Operator(Operator::Add), + Token::NumberLiteral(2), + Token::RightParen, + Token::Operator(Operator::Multiply), + Token::NumberLiteral(3), + ]; + let expression = parse(&tokens).unwrap(); + assert_eq!( + expression, + vec![Expression::Binary( + Operator::Multiply, + Box::new(Expression::Parentheses(Box::new(Expression::Binary( + Operator::Add, + Box::new(Expression::Digit(1)), + Box::new(Expression::Digit(2)), + )))), + Box::new(Expression::Digit(3)), + )], + ); + } + + #[test] + fn test_parse_equals() { + let tokens = vec![ + Token::NumberLiteral(1), + Token::Equals, + Token::NumberLiteral(2), + Token::Equals, + Token::NumberLiteral(3), + ]; + let expression = parse(&tokens).unwrap(); + assert_eq!( + expression, + vec![ + Expression::Digit(1), + Expression::Digit(2), + Expression::Digit(3), + ], + ); + } +} diff --git a/board-shared/src/tile.rs b/board-shared/src/tile.rs new file mode 100644 index 0000000..644393f --- /dev/null +++ b/board-shared/src/tile.rs @@ -0,0 +1,176 @@ +use std::fmt; + +/// A single digit that can be wrapped in parentheses. +#[derive(Debug, PartialEq, Eq, Copy, Clone)] +pub struct Digit { + pub value: u8, + pub has_left_parenthesis: bool, + pub has_right_parenthesis: bool, +} + +impl Digit { + pub fn new(value: u8) -> Self { + Self { + value, + has_left_parenthesis: false, + has_right_parenthesis: false, + } + } +} + +impl fmt::Display for Digit { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if self.has_left_parenthesis { + write!(f, "(")?; + } + write!(f, "{}", self.value)?; + if self.has_right_parenthesis { + write!(f, ")")?; + } + Ok(()) + } +} + +impl TryFrom<&str> for Digit { + type Error = (); + + fn try_from(value: &str) -> Result { + let mut res = Digit { + value: 0, + has_left_parenthesis: false, + has_right_parenthesis: false, + }; + let mut it = value.chars(); + let c = it.next().ok_or(())?; + if c == '(' { + res.has_left_parenthesis = true; + res.value = it.next().ok_or(())?.to_digit(10).ok_or(())? as u8; + } else { + res.value = c.to_digit(10).ok_or(())? as u8; + } + if let Some(c) = it.next() { + if c != ')' { + return Err(()); + } + res.has_right_parenthesis = true; + } + Ok(res) + } +} + +/// An operator that can be applied between two terms. +#[derive(Debug, PartialEq, Eq, Copy, Clone)] +pub enum Operator { + Add, + Subtract, + Multiply, + Divide, +} + +impl fmt::Display for Operator { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Operator::Add => write!(f, "+"), + Operator::Subtract => write!(f, "-"), + Operator::Multiply => write!(f, "*"), + Operator::Divide => write!(f, "/"), + } + } +} + +impl TryFrom for Operator { + type Error = (); + + fn try_from(value: char) -> Result { + match value { + '+' => Ok(Operator::Add), + '-' => Ok(Operator::Subtract), + '*' => Ok(Operator::Multiply), + '/' => Ok(Operator::Divide), + _ => Err(()), + } + } +} + +/// A single piece of a mathematical expression. +#[derive(Debug, PartialEq, Eq, Copy, Clone)] +pub enum Tile { + Digit(Digit), + Operator(Operator), + Equals, +} + +impl TryFrom<&str> for Tile { + type Error = (); + + fn try_from(value: &str) -> Result { + if let Ok(digit) = Digit::try_from(value) { + return Ok(Tile::Digit(digit)); + } + match value { + "=" => Ok(Tile::Equals), + _ => Ok(Tile::Operator(value.chars().next().ok_or(())?.try_into()?)), + } + } +} + +impl fmt::Display for Tile { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Tile::Digit(digit) => write!(f, "{digit}"), + Tile::Operator(operator) => write!(f, "{operator}"), + Tile::Equals => write!(f, "="), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn digit_from_str() { + assert_eq!(Digit::try_from("1"), Ok(Digit::new(1))); + assert_eq!( + Digit::try_from("(5"), + Ok(Digit { + value: 5, + has_left_parenthesis: true, + has_right_parenthesis: false, + }) + ); + assert_eq!( + Digit::try_from("8)"), + Ok(Digit { + value: 8, + has_left_parenthesis: false, + has_right_parenthesis: true, + }) + ); + assert_eq!(Digit::try_from("+"), Err(())); + assert_eq!(Digit::try_from("1("), Err(())); + assert_eq!(Digit::try_from(""), Err(())); + } + + #[test] + fn operator_from_str() { + assert_eq!(Operator::try_from('+'), Ok(Operator::Add)); + assert_eq!(Operator::try_from('-'), Ok(Operator::Subtract)); + assert_eq!(Operator::try_from('²'), Err(())); + } + + #[test] + fn piece_from_str() { + assert_eq!(Tile::try_from("+"), Ok(Tile::Operator(Operator::Add))); + assert_eq!( + Tile::try_from("(7)"), + Ok(Tile::Digit(Digit { + value: 7, + has_left_parenthesis: true, + has_right_parenthesis: true, + })) + ); + assert_eq!(Tile::try_from("="), Ok(Tile::Equals)); + assert_eq!(Tile::try_from(""), Err(())); + } +}