parseSan method
Parses a move in Standard Algebraic Notation.
Returns a legal Move of the Position or null.
Implementation
Move? parseSan(String sanString) {
final aIndex = 'a'.codeUnits[0];
final hIndex = 'h'.codeUnits[0];
final oneIndex = '1'.codeUnits[0];
final eightIndex = '8'.codeUnits[0];
String san = sanString;
final firstAnnotationIndex = san.indexOf(RegExp('[!?#+]'));
if (firstAnnotationIndex != -1) {
san = san.substring(0, firstAnnotationIndex);
}
// Crazyhouse
if (san.contains('@')) {
if (san.length == 3 && san[0] != '@') {
return null;
}
if (san.length == 4 && san[1] != '@') {
return null;
}
final Role role;
if (san.length == 3) {
role = Role.pawn;
} else if (san.length == 4) {
final parsedRole = Role.fromChar(san[0]);
if (parsedRole == null) {
return null;
}
role = parsedRole;
} else {
return null;
}
final destination = Square.parse(san.substring(san.length - 2));
if (destination == null) {
return null;
}
final move = DropMove(to: destination, role: role);
if (!isLegal(move)) {
return null;
}
return move;
}
if (san == 'O-O') {
final king = board.kingOf(turn);
final rook = castles.rookOf(turn, CastlingSide.king);
if (king == null || rook == null) {
return null;
}
final move = NormalMove(from: king, to: rook);
if (!isLegal(move)) {
return null;
}
return move;
}
if (san == 'O-O-O') {
final king = board.kingOf(turn);
final rook = castles.rookOf(turn, CastlingSide.queen);
if (king == null || rook == null) {
return null;
}
final move = NormalMove(from: king, to: rook);
if (!isLegal(move)) {
return null;
}
return move;
}
final isPromotion = san.contains('=');
final isCapturing = san.contains('x');
Rank? pawnRank;
if (oneIndex <= san.codeUnits[0] && san.codeUnits[0] <= eightIndex) {
pawnRank = Rank(san.codeUnits[0] - oneIndex);
san = san.substring(1);
}
final isPawnMove = aIndex <= san.codeUnits[0] && san.codeUnits[0] <= hIndex;
if (isPawnMove) {
// Every pawn move has a destination (e.g. d4)
// Optionally, pawn moves have a promotion
// If the move is a capture then it will include the source file
final colorFilter = board.bySide(turn);
final pawnFilter = board.byRole(Role.pawn);
SquareSet filter = colorFilter.intersect(pawnFilter);
Role? promotionRole;
// We can look at the first character of any pawn move
// in order to determine which file the pawn will be moving
// from
final sourceFileCharacter = san.codeUnits[0];
if (sourceFileCharacter < aIndex || sourceFileCharacter > hIndex) {
return null;
}
final sourceFile = File(sourceFileCharacter - aIndex);
final sourceFileFilter = SquareSet.fromFile(sourceFile);
filter = filter.intersect(sourceFileFilter);
if (isCapturing) {
// Invalid SAN
if (san[1] != 'x') {
return null;
}
// Remove the source file character and the capture marker
san = san.substring(2);
}
if (isPromotion) {
// Invalid SAN
if (san[san.length - 2] != '=') {
return null;
}
final promotionCharacter = san[san.length - 1];
promotionRole = Role.fromChar(promotionCharacter);
// Remove the promotion string
san = san.substring(0, san.length - 2);
}
// After handling captures and promotions, the
// remaining destination square should contain
// two characters.
if (san.length != 2) {
return null;
}
final destination = Square.parse(san);
if (destination == null) {
return null;
}
// There may be many pawns in the corresponding file
// The corect choice will always be the pawn behind the destination square that is furthest down the board
for (final rank in Rank.values) {
final rankFilter = SquareSet.fromRank(rank).complement();
// If the square is behind or on this rank, the rank it will not contain the source pawn
if (turn == Side.white && rank >= destination.rank ||
turn == Side.black && rank <= destination.rank) {
filter = filter.intersect(rankFilter);
}
}
// If the pawn rank has been overspecified, then verify the rank
if (pawnRank != null) {
filter = filter.intersect(SquareSet.fromRank(pawnRank));
}
final source = (turn == Side.white) ? filter.last : filter.first;
// There are no valid candidates for the move
if (source == null) {
return null;
}
final move =
NormalMove(from: source, to: destination, promotion: promotionRole);
if (!isLegal(move)) {
return null;
}
return move;
}
// The final two moves define the destination
final destination = Square.parse(san.substring(san.length - 2));
if (destination == null) {
return null;
}
san = san.substring(0, san.length - 2);
if (isCapturing) {
// Invalid SAN
if (san[san.length - 1] != 'x') {
return null;
}
san = san.substring(0, san.length - 1);
}
// For non-pawn moves, the first character describes a role
final role = Role.fromChar(san[0]);
if (role == null) {
return null;
}
if (role == Role.pawn) {
return null;
}
san = san.substring(1);
final colorFilter = board.bySide(turn);
final roleFilter = board.byRole(role);
SquareSet filter = colorFilter.intersect(roleFilter);
// The remaining characters disambiguate the moves
if (san.length > 2) {
return null;
}
if (san.length == 2) {
final sourceSquare = Square.parse(san);
if (sourceSquare == null) {
return null;
}
final squareFilter = SquareSet.fromSquare(sourceSquare);
filter = filter.intersect(squareFilter);
}
if (san.length == 1) {
final sourceCharacter = san.codeUnits[0];
if (oneIndex <= sourceCharacter && sourceCharacter <= eightIndex) {
final rank = Rank(sourceCharacter - oneIndex);
final rankFilter = SquareSet.fromRank(rank);
filter = filter.intersect(rankFilter);
} else if (aIndex <= sourceCharacter && sourceCharacter <= hIndex) {
final file = File(sourceCharacter - aIndex);
final fileFilter = SquareSet.fromFile(file);
filter = filter.intersect(fileFilter);
} else {
return null;
}
}
Move? move;
for (final square in filter.squares) {
final candidateMove = NormalMove(from: square, to: destination);
if (!isLegal(candidateMove)) {
continue;
}
if (move == null) {
move = candidateMove;
} else {
// Ambiguous notation
return null;
}
}
if (move == null) {
return null;
}
return move;
}