xchess
Version:
Chess Engine
523 lines (440 loc) • 8.45 kB
JavaScript
export {PGNParser as Parser, parse}
import {PGNSyntaxError} from './error.js'
import {DEC, NON_ZERO_DEC, TAG_CHARS, FILES, RANKS, PIECES, PROMOTION_PIECES, RESULTS} from './const.js'
function parse(pgn, config){
return PGNParser.parse(pgn, config);
}
function parseMove(move, config){
return PGNParser.parseMove(move, config);
}
function CommentsToItems(comments){
return comments.map(comment => ({type: 'comment', comment}));
}
class PGNParser {
static parse(pgn, config){
const parser = new this(pgn, config);
return parser.parse();
}
static parseMove(value, config){
const parser = new this(value, config);
return parser.move();
}
#source;
#offset;
#saved;
#comments = [];
constructor(source, {offset = 0} = {}){
this.#source = String(source);
this.#offset = Math.trunc(offset);
this.#saved = this.offset;
}
get source(){
return this.#source;
}
get offset(){
return this.#offset;
}
get length(){
return this.#source.length;
}
get saved(){
return this.#saved;
}
get hasNext(){
return this.offset < this.length;
}
get rest(){
return this.source.substring(this.offset);
}
get chunk(){
return this.source.substring(this.saved, this.offset);
}
get char(){
return this.source[this.offset];
}
// Text
save(){
this.#saved = this.offset;
}
restore(){
this.#offset = this.saved;
}
release(){
const text = this.chunk;
this.#saved = this.offset;
return text;
}
// Test
eq(char){
return this.char === char;
}
not(char){
return this.char !== char;
}
in(chars){
return chars.includes(this.char);
}
test(value){
return this.source.startsWith(value, this.offset);
}
is(value){
if(this.test(value)){
this.#offset += value.length;
return true;
} return false;
}
enum(... args){
for(const arg of args)
if(this.is(arg))
return arg;
return null;
}
req(char){
if(this.eq(char))
this.next();
else
this.throw(`Expected '${char}'`);
}
// parsing
throw(message = 'Syntax Error'){
throw new PGNSyntaxError(message, this);
}
next(){
this.#offset ++;
}
find(chars){
while(true){
if(!this.hasNext)
return false;
if(this.in(chars))
return true;
this.next();
}
}
trim(){
while(this.isWS) this.next();
}
// chars
get isWS(){
return /\s/.test(this.char);
}
get isTagChar(){
return this.in(TAG_CHARS);
}
get isDec(){
return this.in(DEC);
}
get isNonZeroDec(){
return this.in(NON_ZERO_DEC);
}
get isDot(){
return this.eq('.');
}
get isFile(){
return this.in(FILES);
}
get isRank(){
return this.in(RANKS);
}
get isPiece(){
return this.in(PIECES);
}
get isPromotionPiece(){
return this.in(PROMOTION_PIECES);
}
get isCapture(){
return this.eq('x');
}
// parsing
comments(){
const comments = [];
while(true){
const item = this.comment();
if(item){
comments.push(item.comment);
this.trim();
continue;
} break;
} return comments;
}
comment(){
return this.comment1() || this.comment2();
}
comment1(){
if(this.eq('{')){
this.next();
this.save();
this.find('}');
const type = 'comment';
const comment = this.release();
this.req('}');
return {type, comment};
} return null;
}
comment2(){
if(this.eq(';')){
this.next();
this.save();
this.find('\r\n');
const type = 'comment';
const comment = this.release();
return {type, comment};
} return null;
}
tags(){
const tags = {};
while(this.tag(tags))
this.trim();
return tags;
}
tag(tags){
if(this.eq('[')){
this.next();
const name = this.name();
this.trim();
const value = this.value();
this.trim();
this.req(']');
tags[name] = value;
return true;
} return false;
}
name(){
this.save();
while(this.isTagChar)
this.next();
return this.release();
}
value(){
this.req('"');
this.save();
this.find('"');
const value = this.release();
this.req('"');
return value;
}
result(){
return this.enum(... RESULTS);
}
movetext(){
const movetext = [];
while(true){
const result = this.result();
if(result)
return {movetext, result};
const item = this.item();
if(item){
movetext.push(item);
if(item.comments)
movetext.push(... CommentsToItems(item.comments));
this.trim();
} else if(movetext.length > 0)
return {movetext};
else break;
}
}
item(){
return this.comment() || this.number() || this.move();
}
number(){
if(this.isNonZeroDec){
const type = 'number';
const number = this.int();
if(this.ellipsis())
return {type, number, ellipsis: true};
this.req('.');
return {type, number};
}
}
int(){
this.save();
while(this.isDec)
this.next();
return + this.release();
}
ellipsis(){
return this.enum('...', '…');
}
file(){
if(this.isFile){
const file = this.char;
this.next();
return file;
}
}
rank(){
if(this.isRank){
const rank = this.char;
this.next();
return rank;
}
}
square(){
const file = this.file();
const rank = this.rank();
if(file && rank)
return file + rank;
return file || rank || null;
}
piece(){
if(this.isPiece){
const piece = this.char;
this.next();
return piece;
} return null;
}
capture(){
if(this.isCapture){
this.next();
return true;
}
if(this.eq('-'))
this.next();
return false;
}
promotion(){
if(this.in('=/')){
this.next();
if(this.isPromotionPiece){
const promotion = this.char;
this.next();
return promotion;
}
this.throw();
} return null;
}
suffix(){
if(this.in('?!')){
const suffix = this.char;
this.next();
if(this.in('?!')){
const suffix2 = suffix + this.char;
this.next();
return suffix2;
} return suffix;
} return null;
}
sanArgs(){
const args = {};
if(this.is('+'))
args.check = true;
else if(this.is('#'))
args.checkmate = true;
const suffix = this.suffix();
if(suffix)
args.suffix = suffix;
return args;
}
longCastling(){
if(this.is('O-O-O') || this.is('0-0-0'))
return {castling: true, longCastling: true};
}
shortCastling(){
if(this.is('O-O') || this.is('0-0'))
return {castling: true, shortCastling: true};
}
castling(){
return this.longCastling() || this.shortCastling()
}
san(){
const piece = this.piece();
const from = this.square();
const capture = this.capture();
const to = this.square();
if(piece || from || capture){
const promotion = this.promotion();
if(promotion)
return {piece, from, to, capture, promotion};
return {piece, from, to, capture};
}
}
move(){
this.save();
const san = this.castling() || this.san();
if(san){
const type = 'move';
const sanArgs = this.sanArgs();
const move = this.release();
this.trim();
const moveArgs = this.moveArgs();
return {type, move, ... san, ... sanArgs, ... moveArgs};
} this.restore();
}
nag(nags){
if(this.eq('$')){
this.next();
if(this.isDec){
nags.push(this.int());
return true;
} this.throw(`expected a decimal digit, but found '${this.char}'`);
} return false;
}
nags(){
const nags = [];
while(this.nag(nags))
this.trim();
return nags;
}
rav(ravs){
if(this.eq('(')){
this.next();
const moves = [];
while(true){
const move = this.item();
if(move){
moves.push(move);
if(move.comments)
moves.push(... CommentsToItems(move.comments));
this.trim();
} else break;
}
this.req(')');
if(moves.length > 0)
ravs.push(moves);
return true;
} return false;
}
ravs(){
const ravs = [];
while(this.rav(ravs))
this.trim();
return ravs;
}
moveArgs(){
const comments = [];
comments.push(... this.comments());
const nags = this.nags();
comments.push(... this.comments());
const ravs = this.ravs();
comments.push(... this.comments());
return {nags, ravs, comments};
}
game(){
this.trim();
const comments = this.comments();
const tags = this.tags();
this.trim();
const movetext = this.movetext();
if(movetext)
return {tags, comments, ... movetext};
return null;
}
games(){
const games = [];
while(this.hasNext){
this.trim();
const game = this.game();
if(game) games.push(game);
else break;
} return games;
}
end(){
if(this.hasNext)
this.throw(`unexpected token '${this.char}'`);
}
parse(){
const games = this.games();
this.end();
return games;
}
}