boardcast
Version:
Animation library for tabletop game rules on hex boards with CLI tools and game extensions
386 lines (333 loc) • 8.4 kB
JavaScript
import {
createToken,
Lexer,
CstParser,
tokenMatcher
} from 'chevrotain';
/**
* Boardcast DSL Parser using Chevrotain
* Provides robust parsing for .board files with detailed error reporting
*/
// Token definitions
const WhiteSpace = createToken({
name: "WhiteSpace",
pattern: /\s+/,
group: Lexer.SKIPPED
});
const Comment = createToken({
name: "Comment",
pattern: /#[^\r\n]*/,
group: Lexer.SKIPPED
});
const Identifier = createToken({
name: "Identifier",
pattern: /[a-zA-Z_][a-zA-Z0-9_]*/
});
const StringLiteral = createToken({
name: "StringLiteral",
pattern: /"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/
});
const NumberLiteral = createToken({
name: "NumberLiteral",
pattern: /-?\d+(?:\.\d+)?/
});
const BooleanLiteral = createToken({
name: "BooleanLiteral",
pattern: /true|false/
});
const Dot = createToken({
name: "Dot",
pattern: /\./
});
const LeftParen = createToken({
name: "LeftParen",
pattern: /\(/
});
const RightParen = createToken({
name: "RightParen",
pattern: /\)/
});
const Comma = createToken({
name: "Comma",
pattern: /,/
});
const NewLine = createToken({
name: "NewLine",
pattern: /\r?\n/
});
// All tokens
const allTokens = [
WhiteSpace,
Comment,
NewLine,
StringLiteral,
NumberLiteral,
BooleanLiteral,
LeftParen,
RightParen,
Comma,
Dot,
Identifier
];
// Lexer
const BoardLexer = new Lexer(allTokens);
// Parser
class BoardParser extends CstParser {
constructor() {
super(allTokens);
this.performSelfAnalysis();
}
program = this.RULE("program", () => {
this.MANY(() => {
this.OR([
{ ALT: () => this.SUBRULE(this.command) },
{ ALT: () => this.CONSUME(NewLine) }
]);
});
});
command = this.RULE("command", () => {
this.SUBRULE(this.methodCall);
this.OPTION(() => this.CONSUME(NewLine));
});
methodCall = this.RULE("methodCall", () => {
this.CONSUME(Identifier, { LABEL: "methodName" });
this.CONSUME(LeftParen);
this.OPTION(() => this.SUBRULE(this.argumentList));
this.CONSUME(RightParen);
});
argumentList = this.RULE("argumentList", () => {
this.SUBRULE(this.argument);
this.MANY(() => {
this.CONSUME(Comma);
this.SUBRULE2(this.argument);
});
});
argument = this.RULE("argument", () => {
this.OR([
{ ALT: () => this.SUBRULE(this.stringLiteral) },
{ ALT: () => this.SUBRULE(this.numberLiteral) },
{ ALT: () => this.SUBRULE(this.booleanLiteral) },
{ ALT: () => this.SUBRULE(this.enumValue) },
{ ALT: () => this.CONSUME(Identifier) }
]);
});
stringLiteral = this.RULE("stringLiteral", () => {
this.CONSUME(StringLiteral);
});
numberLiteral = this.RULE("numberLiteral", () => {
this.CONSUME(NumberLiteral);
});
booleanLiteral = this.RULE("booleanLiteral", () => {
this.CONSUME(BooleanLiteral);
});
enumValue = this.RULE("enumValue", () => {
this.CONSUME(Identifier, { LABEL: "enumType" });
this.CONSUME(Dot);
this.CONSUME2(Identifier, { LABEL: "enumValue" });
});
}
// Parser instance
const parserInstance = new BoardParser();
/**
* Visitor for converting CST to AST and performing semantic analysis
*/
class BoardInterpreter extends parserInstance.getBaseCstVisitorConstructor() {
constructor() {
super();
this.validateVisitor();
}
program(ctx) {
const commands = [];
if (ctx.command) {
ctx.command.forEach(commandCtx => {
const command = this.visit(commandCtx);
if (command) {
commands.push(command);
}
});
}
return commands;
}
command(ctx) {
return this.visit(ctx.methodCall);
}
methodCall(ctx) {
const methodName = ctx.methodName[0].image;
const args = ctx.argumentList ? this.visit(ctx.argumentList) : [];
return {
method: methodName,
args: args,
location: {
startLine: ctx.methodName[0].startLine,
startColumn: ctx.methodName[0].startColumn,
endLine: ctx.RightParen[0].endLine,
endColumn: ctx.RightParen[0].endColumn
}
};
}
argumentList(ctx) {
return ctx.argument.map(argCtx => this.visit(argCtx));
}
argument(ctx) {
if (ctx.stringLiteral) {
return this.visit(ctx.stringLiteral);
} else if (ctx.numberLiteral) {
return this.visit(ctx.numberLiteral);
} else if (ctx.booleanLiteral) {
return this.visit(ctx.booleanLiteral);
} else if (ctx.enumValue) {
return this.visit(ctx.enumValue);
} else if (ctx.Identifier) {
return {
type: 'identifier',
value: ctx.Identifier[0].image,
raw: ctx.Identifier[0].image
};
}
}
stringLiteral(ctx) {
const raw = ctx.StringLiteral[0].image;
const value = raw.slice(1, -1); // Remove quotes
return {
type: 'string',
value: value,
raw: raw
};
}
numberLiteral(ctx) {
const raw = ctx.NumberLiteral[0].image;
const value = parseFloat(raw);
return {
type: 'number',
value: value,
raw: raw
};
}
booleanLiteral(ctx) {
const raw = ctx.BooleanLiteral[0].image;
const value = raw === 'true';
return {
type: 'boolean',
value: value,
raw: raw
};
}
enumValue(ctx) {
const enumType = ctx.enumType[0].image;
const enumValue = ctx.enumValue[0].image;
// Handle specific enum types
if (enumType === 'ClearType') {
return {
type: 'string',
value: enumValue,
raw: `${enumType}.${enumValue}`
};
} else if (enumType === 'Colors') {
return {
type: 'enum',
enumType: 'Colors',
value: enumValue,
raw: `${enumType}.${enumValue}`
};
}
return {
type: 'enum',
enumType: enumType,
value: enumValue,
raw: `${enumType}.${enumValue}`
};
}
}
/**
* Parse board file content using Chevrotain
*/
function parseBoardContent(content) {
// Lexical analysis
const lexResult = BoardLexer.tokenize(content);
if (lexResult.errors.length > 0) {
return {
success: false,
errors: lexResult.errors.map(error => ({
type: 'lexical',
message: error.message,
line: error.line,
column: error.column,
length: error.length
}))
};
}
// Set the parser input
parserInstance.input = lexResult.tokens;
// Parse
const cst = parserInstance.program();
if (parserInstance.errors.length > 0) {
return {
success: false,
errors: parserInstance.errors.map(error => ({
type: 'syntactic',
message: error.message,
line: error.token?.startLine,
column: error.token?.startColumn,
expectedTokenTypes: error.expectedTokenTypes?.map(t => t.name),
actualToken: error.token?.image
}))
};
}
// Convert CST to AST
const interpreter = new BoardInterpreter();
const commands = interpreter.visit(cst);
return {
success: true,
commands: commands,
tokens: lexResult.tokens
};
}
/**
* Parse a board file from file path
*/
async function parseBoardFile(filePath) {
try {
const fs = await import('fs');
const content = fs.readFileSync(filePath, 'utf-8');
return parseBoardContent(content);
} catch (error) {
return {
success: false,
errors: [{
type: 'file',
message: `Failed to read file: ${error.message}`,
filePath: filePath
}]
};
}
}
/**
* Format parsing errors for display
*/
function formatParsingError(error, content) {
const lines = content.split('\n');
let message = `${error.type.toUpperCase()} ERROR: ${error.message}`;
if (error.line && error.line <= lines.length) {
const line = lines[error.line - 1];
message += `\n Line ${error.line}: ${line.trim()}`;
if (error.column) {
const pointer = ' '.repeat(error.column + 8) + '^';
message += `\n${pointer}`;
}
}
if (error.expectedTokenTypes && error.expectedTokenTypes.length > 0) {
message += `\n Expected: ${error.expectedTokenTypes.join(', ')}`;
}
if (error.actualToken) {
message += `\n Found: "${error.actualToken}"`;
}
return message;
}
export {
parseBoardContent,
parseBoardFile,
formatParsingError,
BoardLexer,
BoardParser,
BoardInterpreter
};