fix: lint, type errors

test: specify types for path checks in interpreter tests

test: add optional chaining in lexer tests to prevent errors

test: add optional chaining in parser tests to prevent errors

fix: clean up whitespace and formatting in AST and lexer files

feat: enhance CLI with improved error handling and user prompts

fix: update index and interpreter files for better type handling

fix: clean up parser code for better readability and error handling

build: enable minification in tsdown configuration
This commit is contained in:
2025-09-01 02:44:45 +07:00
parent eb357ef703
commit 13d79e37bb
14 changed files with 279 additions and 248 deletions

View File

@@ -138,6 +138,6 @@ describe('questLang Integration', () => {
interpreter.moveToNode('выбор_пути');
const choices = interpreter.getAvailableChoices();
expect(choices).toHaveLength(3);
expect(choices[0].text).toBe('Пойти в дешёвую шашлычную');
expect(choices[0]?.text).toBe('Пойти в дешёвую шашлычную');
});
});

View File

@@ -170,7 +170,7 @@ describe('questInterpreter', () => {
expect(paths.length).toBeGreaterThan(0);
// Should have at least two paths (налево->победа and направо)
expect(paths.some(path => path.includes('победа'))).toBe(true);
expect(paths.some(path => path.includes('направо'))).toBe(true);
expect(paths.some((path: string) => path.includes('победа'))).toBe(true);
expect(paths.some((path: string) => path.includes('направо'))).toBe(true);
});
});

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import { Lexer } from '../lexer';
import { TokenType } from '..';
import { Lexer } from '../lexer';
describe('lexer', () => {
it('should tokenize quest keywords', () => {
@@ -8,19 +8,19 @@ describe('lexer', () => {
const lexer = new Lexer(source);
const tokens = lexer.tokenize();
expect(tokens[0].type).toBe(TokenType.QUEST);
expect(tokens[0].value).toBe('квест');
expect(tokens[0]?.type).toBe(TokenType.QUEST);
expect(tokens[0]?.value).toBe('квест');
expect(tokens[1].type).toBe(TokenType.IDENTIFIER);
expect(tokens[1].value).toBe('Тест');
expect(tokens[1]?.type).toBe(TokenType.IDENTIFIER);
expect(tokens[1]?.value).toBe('Тест');
expect(tokens[2].type).toBe(TokenType.SEMICOLON);
expect(tokens[2]?.type).toBe(TokenType.SEMICOLON);
expect(tokens[3].type).toBe(TokenType.GOAL);
expect(tokens[3].value).toBe('цель');
expect(tokens[3]?.type).toBe(TokenType.GOAL);
expect(tokens[3]?.value).toBe('цель');
expect(tokens[4].type).toBe(TokenType.STRING);
expect(tokens[4].value).toBe('Описание');
expect(tokens[4]?.type).toBe(TokenType.STRING);
expect(tokens[4]?.value).toBe('Описание');
});
it('should tokenize strings correctly', () => {
@@ -28,8 +28,8 @@ describe('lexer', () => {
const lexer = new Lexer(source);
const tokens = lexer.tokenize();
expect(tokens[0].type).toBe(TokenType.STRING);
expect(tokens[0].value).toBe('Тестовая строка с пробелами');
expect(tokens[0]?.type).toBe(TokenType.STRING);
expect(tokens[0]?.value).toBe('Тестовая строка с пробелами');
});
it('should tokenize numbers', () => {
@@ -37,11 +37,11 @@ describe('lexer', () => {
const lexer = new Lexer(source);
const tokens = lexer.tokenize();
expect(tokens[0].type).toBe(TokenType.NUMBER);
expect(tokens[0].value).toBe('42');
expect(tokens[0]?.type).toBe(TokenType.NUMBER);
expect(tokens[0]?.value).toBe('42');
expect(tokens[1].type).toBe(TokenType.NUMBER);
expect(tokens[1].value).toBe('3.14');
expect(tokens[1]?.type).toBe(TokenType.NUMBER);
expect(tokens[1]?.value).toBe('3.14');
});
it('should handle comments', () => {
@@ -49,10 +49,10 @@ describe('lexer', () => {
const lexer = new Lexer(source);
const tokens = lexer.tokenize();
expect(tokens[0].type).toBe(TokenType.COMMENT);
expect(tokens[0].value).toBe('// это комментарий');
expect(tokens[0]?.type).toBe(TokenType.COMMENT);
expect(tokens[0]?.value).toBe('// это комментарий');
expect(tokens[1].type).toBe(TokenType.QUEST);
expect(tokens[1]?.type).toBe(TokenType.QUEST);
});
it('should track line and column numbers', () => {
@@ -60,11 +60,11 @@ describe('lexer', () => {
const lexer = new Lexer(source);
const tokens = lexer.tokenize();
expect(tokens[0].line).toBe(1);
expect(tokens[0].column).toBe(1);
expect(tokens[0]?.line).toBe(1);
expect(tokens[0]?.column).toBe(1);
expect(tokens[1].line).toBe(2);
expect(tokens[1].column).toBe(1);
expect(tokens[1]?.line).toBe(2);
expect(tokens[1]?.column).toBe(1);
});
it('should handle all symbols', () => {
@@ -86,7 +86,7 @@ describe('lexer', () => {
];
expectedTypes.forEach((expectedType, index) => {
expect(tokens[index].type).toBe(expectedType);
expect(tokens[index]?.type).toBe(expectedType);
});
});

View File

@@ -59,10 +59,10 @@ describe('parser', () => {
const nodes = ast.graph.nodes;
expect(Object.keys(nodes)).toHaveLength(4);
expect(nodes.старт.nodeType).toBe('начальный');
expect(nodes.действие1.nodeType).toBe('действие');
expect(nodes.конец1.nodeType).toBe('концовка');
expect(nodes.конец2.nodeType).toBe('концовка');
expect(nodes.старт?.nodeType).toBe('начальный');
expect(nodes.действие1?.nodeType).toBe('действие');
expect(nodes.конец1?.nodeType).toBe('концовка');
expect(nodes.конец2?.nodeType).toBe('концовка');
});
it('should parse action node with options', () => {

View File

@@ -6,7 +6,7 @@ export enum TokenType {
IDENTIFIER = 'IDENTIFIER',
STRING = 'STRING',
NUMBER = 'NUMBER',
// Keywords
QUEST = 'квест',
GOAL = 'цель',
@@ -19,12 +19,12 @@ export enum TokenType {
TRANSITIONS = 'переходы',
OPTIONS = 'варианты',
TITLE = 'название',
// Node types
INITIAL = 'начальный',
ACTION = 'действие',
ENDING = 'концовка',
// Symbols
SEMICOLON = ';',
COLON = ':',
@@ -36,12 +36,12 @@ export enum TokenType {
RIGHT_BRACKET = ']',
LEFT_PAREN = '(',
RIGHT_PAREN = ')',
// Special
EOF = 'EOF',
NEWLINE = 'NEWLINE',
COMMENT = 'COMMENT',
WHITESPACE = 'WHITESPACE'
WHITESPACE = 'WHITESPACE',
}
/**

View File

@@ -1,9 +1,11 @@
#!/usr/bin/env node
import type { QuestInterpreter } from '.';
import type { InitialNode, NodeDefinition } from './ast.js';
import fs from 'node:fs';
import process from 'node:process';
import * as p from '@clack/prompts';
import { QuestLang, type QuestInterpreter } from '.';
import { type NodeDefinition, type InitialNode } from './ast';
import { QuestLang } from '.';
/**
* Beautiful command-line interface for QuestLang using clack
@@ -14,9 +16,9 @@ class ClackCLI {
*/
public async run(): Promise<void> {
const args = process.argv.slice(2);
console.clear();
p.intro('🎮 QuestLang Interpreter');
if (args.length === 0) {
@@ -25,32 +27,32 @@ class ClackCLI {
}
const command = args[0];
switch (command) {
case 'play':
if (args.length < 2) {
p.outro('❌ Usage: questlang-clack play <file.ql>');
process.exit(1);
}
await this.playQuest(args[1]);
await this.playQuest(args[1]!);
break;
case 'validate':
if (args.length < 2) {
p.outro('❌ Usage: questlang-clack validate <file.ql>');
process.exit(1);
}
await this.validateQuest(args[1]);
await this.validateQuest(args[1]!);
break;
case 'analyze':
if (args.length < 2) {
p.outro('❌ Usage: questlang-clack analyze <file.ql>');
process.exit(1);
}
await this.analyzeQuest(args[1]);
await this.analyzeQuest(args[1]!);
break;
default:
p.outro(`❌ Unknown command: ${command}`);
process.exit(1);
@@ -59,7 +61,7 @@ class ClackCLI {
private async showInteractiveMenu(): Promise<void> {
const questFiles = this.findQuestFiles();
if (questFiles.length === 0) {
p.outro('❌ No .ql quest files found in current directory');
process.exit(1);
@@ -107,25 +109,26 @@ class ClackCLI {
return fs.readdirSync('.')
.filter(file => file.endsWith('.ql'))
.sort();
} catch {
}
catch {
return [];
}
}
private async playQuest(filename: string): Promise<void> {
const spinner = p.spinner();
try {
spinner.start('Loading quest...');
const source = this.readFile(filename);
const interpreter = QuestLang.interpret(source);
// Validate first
const validation = interpreter.validate();
if (!validation.isValid) {
spinner.stop('❌ Quest validation failed');
p.log.error('Validation errors:');
validation.errors.forEach(error => p.log.error(`${error}`));
p.outro('Fix the errors and try again');
@@ -134,12 +137,12 @@ class ClackCLI {
const questInfo = interpreter.getQuestInfo();
spinner.stop('✅ Quest loaded successfully');
p.note(`📖 ${questInfo.goal}`, `🎮 ${questInfo.name}`);
await this.gameLoop(interpreter);
} catch (error) {
}
catch (error) {
spinner.stop('❌ Error loading quest');
p.log.error(error instanceof Error ? error.message : String(error));
p.outro('Failed to start quest');
@@ -150,7 +153,7 @@ class ClackCLI {
private async gameLoop(interpreter: QuestInterpreter): Promise<void> {
while (!interpreter.getState().isComplete) {
const currentNode = interpreter.getCurrentNode();
if (!currentNode) {
p.log.error('Current node not found');
break;
@@ -161,13 +164,13 @@ class ClackCLI {
if (currentNode.nodeType === 'действие') {
const choices = interpreter.getAvailableChoices();
const choice = await p.select({
message: 'What do you want to do?',
options: choices.map((choice, index) => ({
value: index,
label: choice.text
}))
label: choice.text,
})),
});
if (p.isCancel(choice)) {
@@ -176,21 +179,28 @@ class ClackCLI {
}
const result = interpreter.executeChoice(choice as number);
if (!result.success) {
p.log.error(`Error: ${result.error}`);
break;
}
} else if (currentNode.nodeType === 'начальный') {
}
else if (currentNode.nodeType === 'начальный') {
// Auto-advance from initial nodes to first transition
const initialNode = currentNode as InitialNode;
if (initialNode.transitions && initialNode.transitions.length > 0) {
const result = interpreter.moveToNode(initialNode.transitions[0]);
const firstTransition = initialNode.transitions[0];
if (!firstTransition) {
p.log.error('First transition is undefined');
break;
}
const result = interpreter.moveToNode(firstTransition);
if (!result.success) {
p.log.error(`Error: ${result.error}`);
break;
}
} else {
}
else {
p.log.error('Initial node has no transitions');
break;
}
@@ -203,9 +213,9 @@ class ClackCLI {
const finalNode = interpreter.getCurrentNode();
p.note(
finalNode?.description || 'Quest completed',
`🏆 ${state.endingTitle}`
`🏆 ${state.endingTitle}`,
);
const playAgain = await p.confirm({
message: 'Would you like to play again?',
});
@@ -213,7 +223,8 @@ class ClackCLI {
if (!p.isCancel(playAgain) && playAgain) {
interpreter.reset();
await this.gameLoop(interpreter);
} else {
}
else {
p.outro('Thanks for playing! 🎉');
}
}
@@ -221,26 +232,28 @@ class ClackCLI {
private async validateQuest(filename: string): Promise<void> {
const spinner = p.spinner();
try {
spinner.start('Validating quest...');
const source = this.readFile(filename);
const validation = QuestLang.validate(source);
if (validation.isValid) {
spinner.stop('✅ Validation completed');
p.log.success('Quest is valid!');
p.outro('🎉 No issues found');
} else {
}
else {
spinner.stop('❌ Validation failed');
p.log.error('Validation errors:');
validation.errors.forEach(error => p.log.error(`${error}`));
p.outro('Fix the errors and try again');
process.exit(1);
}
} catch (error) {
}
catch (error) {
spinner.stop('❌ Error during validation');
p.log.error(error instanceof Error ? error.message : String(error));
p.outro('Validation failed');
@@ -250,58 +263,59 @@ class ClackCLI {
private async analyzeQuest(filename: string): Promise<void> {
const spinner = p.spinner();
try {
spinner.start('Analyzing quest...');
const source = this.readFile(filename);
const interpreter = QuestLang.interpret(source);
const questInfo = interpreter.getQuestInfo();
spinner.stop('✅ Analysis completed');
const nodes = Object.values(interpreter.getProgram().graph.nodes) as NodeDefinition[];
const initialNodes = nodes.filter(n => n.nodeType === 'начальный');
const actionNodes = nodes.filter(n => n.nodeType === 'действие');
const endingNodes = nodes.filter(n => n.nodeType === 'концовка');
p.note(
`📊 Quest Analysis Results\n\n` +
`Total nodes: ${nodes.length}\n` +
` • Initial nodes: ${initialNodes.length}\n` +
` • Action nodes: ${actionNodes.length}\n` +
` • Ending nodes: ${endingNodes.length}`,
`📖 ${questInfo.name}`
`📊 Quest Analysis Results\n\n`
+ `Total nodes: ${nodes.length}\n`
+ ` • Initial nodes: ${initialNodes.length}\n`
+ ` • Action nodes: ${actionNodes.length}\n`
+ ` • Ending nodes: ${endingNodes.length}`,
`📖 ${questInfo.name}`,
);
// Analyze paths
const paths = interpreter.getAllPaths();
if (paths.length > 0) {
const avgPathLength = paths.reduce((sum, path) => sum + path.length, 0) / paths.length;
const shortestPath = Math.min(...paths.map(path => path.length));
const longestPath = Math.max(...paths.map(path => path.length));
p.log.info('📈 Path Analysis:');
p.log.info(` • Possible paths: ${paths.length}`);
p.log.info(` • Average path length: ${avgPathLength.toFixed(1)} steps`);
p.log.info(` • Shortest path: ${shortestPath} steps`);
p.log.info(` • Longest path: ${longestPath} steps`);
}
// Validation
const validation = interpreter.validate();
if (validation.isValid) {
p.log.success('✅ Quest structure is valid');
p.outro('🎉 Analysis completed successfully');
} else {
}
else {
p.log.warn('⚠️ Quest has validation issues:');
validation.errors.forEach(error => p.log.warn(`${error}`));
p.outro('Consider fixing these issues');
}
} catch (error) {
}
catch (error) {
spinner.stop('❌ Error during analysis');
p.log.error(error instanceof Error ? error.message : String(error));
p.outro('Analysis failed');
@@ -313,14 +327,14 @@ class ClackCLI {
if (!fs.existsSync(filename)) {
throw new Error(`File not found: ${filename}`);
}
return fs.readFileSync(filename, 'utf-8');
}
}
const cli = new ClackCLI();
cli.run().catch(error => {
cli.run().catch((error) => {
p.log.error(`Unexpected error: ${error}`);
p.outro('❌ CLI crashed');
process.exit(1);

View File

@@ -1,7 +1,7 @@
import type { QuestProgram } from './ast';
import { QuestInterpreter } from './interpreter';
import { Lexer } from './lexer';
import { Parser } from './parser';
import { QuestInterpreter } from './interpreter';
import type { QuestProgram } from './ast';
/**
* Main QuestLang processor
@@ -13,7 +13,7 @@ export class QuestLang {
public static parse(source: string): QuestProgram {
const lexer = new Lexer(source);
const tokens = lexer.tokenize();
const parser = new Parser(tokens);
return parser.parse();
}
@@ -33,18 +33,17 @@ export class QuestLang {
try {
const interpreter = this.interpret(source);
return interpreter.validate();
} catch (error) {
}
catch (error) {
return {
isValid: false,
errors: [error instanceof Error ? error.message : 'Unknown parsing error']
errors: [error instanceof Error ? error.message : 'Unknown parsing error'],
};
}
}
}
// Re-export main classes
export * from './ast';
export { QuestInterpreter } from './interpreter';
export { Lexer } from './lexer';
export { Parser } from './parser';
export { QuestInterpreter } from './interpreter';
export * from './ast';
export * from './types';

View File

@@ -1,11 +1,10 @@
import type {
QuestProgram,
GraphNode,
NodeDefinition,
InitialNode,
ActionNode,
EndingNode,
OptionChoice
InitialNode,
NodeDefinition,
OptionChoice,
QuestProgram,
} from './ast';
/**
@@ -31,9 +30,9 @@ export interface ExecutionResult {
* Visitor interface for quest nodes
*/
export interface QuestVisitor {
visitInitialNode(node: InitialNode, state: QuestState): void;
visitActionNode(node: ActionNode, state: QuestState): void;
visitEndingNode(node: EndingNode, state: QuestState): void;
visitInitialNode: (node: InitialNode, state: QuestState) => void;
visitActionNode: (node: ActionNode, state: QuestState) => void;
visitEndingNode: (node: EndingNode, state: QuestState) => void;
}
/**
@@ -48,7 +47,7 @@ export class QuestInterpreter {
this.currentState = {
currentNode: program.graph.start,
history: [],
isComplete: false
isComplete: false,
};
}
@@ -66,7 +65,7 @@ export class QuestInterpreter {
return {
name: this.program.name,
goal: this.program.goal,
isComplete: this.currentState.isComplete
isComplete: this.currentState.isComplete,
};
}
@@ -101,12 +100,12 @@ export class QuestInterpreter {
*/
public executeChoice(choiceIndex: number): ExecutionResult {
const currentNode = this.getCurrentNode();
if (!currentNode) {
return {
success: false,
newState: this.currentState,
error: `Current node '${this.currentState.currentNode}' not found`
error: `Current node '${this.currentState.currentNode}' not found`,
};
}
@@ -114,7 +113,7 @@ export class QuestInterpreter {
return {
success: false,
newState: this.currentState,
error: `Cannot execute choice on node type '${currentNode.nodeType}'`
error: `Cannot execute choice on node type '${currentNode.nodeType}'`,
};
}
@@ -125,11 +124,18 @@ export class QuestInterpreter {
return {
success: false,
newState: this.currentState,
error: `Invalid choice index: ${choiceIndex}. Available choices: 0-${choices.length - 1}`
error: `Invalid choice index: ${choiceIndex}. Available choices: 0-${choices.length - 1}`,
};
}
const choice = choices[choiceIndex];
if (!choice) {
return {
success: false,
newState: this.currentState,
error: `Choice at index ${choiceIndex} is undefined`,
};
}
return this.moveToNode(choice.target);
}
@@ -138,12 +144,12 @@ export class QuestInterpreter {
*/
public moveToNode(nodeId: string): ExecutionResult {
const targetNode = this.program.graph.nodes[nodeId];
if (!targetNode) {
return {
success: false,
newState: this.currentState,
error: `Target node '${nodeId}' not found`
error: `Target node '${nodeId}' not found`,
};
}
@@ -152,14 +158,14 @@ export class QuestInterpreter {
currentNode: nodeId,
history: [...this.currentState.history, this.currentState.currentNode],
isComplete: targetNode.nodeType === 'концовка',
endingTitle: targetNode.nodeType === 'концовка' ? (targetNode as EndingNode).title : undefined
endingTitle: targetNode.nodeType === 'концовка' ? (targetNode as EndingNode).title : undefined,
};
this.currentState = newState;
return {
success: true,
newState: { ...newState }
newState: { ...newState },
};
}
@@ -170,7 +176,7 @@ export class QuestInterpreter {
this.currentState = {
currentNode: this.program.graph.start,
history: [],
isComplete: false
isComplete: false,
};
}
@@ -180,15 +186,15 @@ export class QuestInterpreter {
public getAllPaths(): string[][] {
const paths: string[][] = [];
const visited = new Set<string>();
this.findPaths(this.currentState.currentNode, [this.currentState.currentNode], paths, visited);
return paths;
}
private findPaths(nodeId: string, currentPath: string[], allPaths: string[][], visited: Set<string>): void {
const node = this.program.graph.nodes[nodeId];
if (!node || visited.has(nodeId)) {
return;
}
@@ -205,7 +211,8 @@ export class QuestInterpreter {
for (const option of actionNode.options) {
this.findPaths(option.target, [...currentPath, option.target], allPaths, new Set(visited));
}
} else if (node.nodeType === 'начальный') {
}
else if (node.nodeType === 'начальный') {
const initialNode = node as InitialNode;
for (const transition of initialNode.transitions) {
this.findPaths(transition, [...currentPath, transition], allPaths, new Set(visited));
@@ -234,7 +241,8 @@ export class QuestInterpreter {
errors.push(`Node '${nodeId}' references non-existent target '${option.target}'`);
}
}
} else if (node.nodeType === 'начальный') {
}
else if (node.nodeType === 'начальный') {
const initialNode = node as InitialNode;
for (const transition of initialNode.transitions) {
if (!this.program.graph.nodes[transition]) {
@@ -247,7 +255,7 @@ export class QuestInterpreter {
// Check for unreachable nodes
const reachable = new Set<string>();
this.findReachableNodes(this.program.graph.start, reachable);
for (const nodeId of nodeIds) {
if (!reachable.has(nodeId)) {
errors.push(`Node '${nodeId}' is unreachable`);
@@ -256,15 +264,17 @@ export class QuestInterpreter {
return {
isValid: errors.length === 0,
errors
errors,
};
}
private findReachableNodes(nodeId: string, reachable: Set<string>): void {
if (reachable.has(nodeId)) return;
if (reachable.has(nodeId))
return;
const node = this.program.graph.nodes[nodeId];
if (!node) return;
if (!node)
return;
reachable.add(nodeId);
@@ -273,7 +283,8 @@ export class QuestInterpreter {
for (const option of actionNode.options) {
this.findReachableNodes(option.target, reachable);
}
} else if (node.nodeType === 'начальный') {
}
else if (node.nodeType === 'начальный') {
const initialNode = node as InitialNode;
for (const transition of initialNode.transitions) {
this.findReachableNodes(transition, reachable);

View File

@@ -1,5 +1,5 @@
import type { Token } from './ast';
import { TokenType } from './ast';
import type { Token, Position } from './ast';
/**
* Lexical analyzer for QuestLang
@@ -26,7 +26,7 @@ export class Lexer {
['название', TokenType.TITLE],
['начальный', TokenType.INITIAL],
['действие', TokenType.ACTION],
['концовка', TokenType.ENDING]
['концовка', TokenType.ENDING],
]);
constructor(source: string) {
@@ -100,7 +100,8 @@ export class Lexer {
case '/':
if (this.match('/')) {
this.scanComment(start, startLine, startColumn);
} else {
}
else {
throw new Error(`Unexpected character: ${c} at ${startLine}:${startColumn}`);
}
break;
@@ -110,9 +111,11 @@ export class Lexer {
default:
if (this.isDigit(c)) {
this.scanNumber(start, startLine, startColumn);
} else if (this.isAlpha(c)) {
}
else if (this.isAlpha(c)) {
this.scanIdentifier(start, startLine, startColumn);
} else {
}
else {
throw new Error(`Unexpected character: ${c} at ${startLine}:${startColumn}`);
}
break;
@@ -182,7 +185,7 @@ export class Lexer {
line: line || this.line,
column: column || this.column,
start: start || this.position,
end: this.position
end: this.position,
});
}
@@ -194,20 +197,24 @@ export class Lexer {
}
private match(expected: string): boolean {
if (this.isAtEnd()) return false;
if (this.source.charAt(this.position) !== expected) return false;
if (this.isAtEnd())
return false;
if (this.source.charAt(this.position) !== expected)
return false;
this.position++;
this.column++;
return true;
}
private peek(): string {
if (this.isAtEnd()) return '\0';
if (this.isAtEnd())
return '\0';
return this.source.charAt(this.position);
}
private peekNext(): string {
if (this.position + 1 >= this.source.length) return '\0';
if (this.position + 1 >= this.source.length)
return '\0';
return this.source.charAt(this.position + 1);
}
@@ -220,12 +227,12 @@ export class Lexer {
}
private isAlpha(c: string): boolean {
return (c >= 'a' && c <= 'z') ||
(c >= 'A' && c <= 'Z') ||
(c >= 'а' && c <= 'я') ||
(c >= 'А' && c <= 'Я') ||
c === 'ё' || c === 'Ё' ||
c === '_';
return (c >= 'a' && c <= 'z')
|| (c >= 'A' && c <= 'Z')
|| (c >= 'а' && c <= 'я')
|| (c >= 'А' && c <= 'Я')
|| c === 'ё' || c === 'Ё'
|| c === '_';
}
private isAlphaNumeric(c: string): boolean {

View File

@@ -1,17 +1,5 @@
import type { ActionNode, EndingNode, GraphNode, InitialNode, NodeDefinition, OptionChoice, QuestProgram, Token } from './ast';
import { TokenType } from './ast';
import type { Token } from './ast';
import type {
ASTNode,
QuestProgram,
GraphNode,
NodeDefinition,
InitialNode,
ActionNode,
EndingNode,
OptionChoice,
StringLiteral,
Identifier
} from './ast';
/**
* Parser for QuestLang
@@ -22,9 +10,9 @@ export class Parser {
constructor(tokens: Token[]) {
// Filter out comments and whitespace
this.tokens = tokens.filter(token =>
token.type !== TokenType.COMMENT &&
token.type !== TokenType.WHITESPACE
this.tokens = tokens.filter(token =>
token.type !== TokenType.COMMENT
&& token.type !== TokenType.WHITESPACE,
);
}
@@ -36,15 +24,15 @@ export class Parser {
}
private parseQuest(): QuestProgram {
const questToken = this.consume(TokenType.QUEST, "Expected 'квест'");
const name = this.consume(TokenType.IDENTIFIER, "Expected quest name").value;
this.consume(TokenType.SEMICOLON, "Expected ';' after quest name");
const questToken = this.consume(TokenType.QUEST, 'Expected \'квест\'');
const name = this.consume(TokenType.IDENTIFIER, 'Expected quest name').value;
this.consume(TokenType.SEMICOLON, 'Expected \';\' after quest name');
const goal = this.parseGoal();
const graph = this.parseGraph();
this.consume(TokenType.END, "Expected 'конец'");
this.consume(TokenType.SEMICOLON, "Expected ';' after 'конец'");
this.consume(TokenType.END, 'Expected \'конец\'');
this.consume(TokenType.SEMICOLON, 'Expected \';\' after \'конец\'');
return {
type: 'QuestProgram',
@@ -52,20 +40,20 @@ export class Parser {
goal,
graph,
line: questToken.line,
column: questToken.column
column: questToken.column,
};
}
private parseGoal(): string {
this.consume(TokenType.GOAL, "Expected 'цель'");
const goalValue = this.consume(TokenType.STRING, "Expected goal description").value;
this.consume(TokenType.SEMICOLON, "Expected ';' after goal");
this.consume(TokenType.GOAL, 'Expected \'цель\'');
const goalValue = this.consume(TokenType.STRING, 'Expected goal description').value;
this.consume(TokenType.SEMICOLON, 'Expected \';\' after goal');
return goalValue;
}
private parseGraph(): GraphNode {
const graphToken = this.consume(TokenType.GRAPH, "Expected 'граф'");
this.consume(TokenType.LEFT_BRACE, "Expected '{' after 'граф'");
const graphToken = this.consume(TokenType.GRAPH, 'Expected \'граф\'');
this.consume(TokenType.LEFT_BRACE, 'Expected \'{\' after \'граф\'');
const nodes: Record<string, NodeDefinition> = {};
let start = '';
@@ -73,41 +61,43 @@ export class Parser {
while (!this.check(TokenType.RIGHT_BRACE) && !this.isAtEnd()) {
if (this.match(TokenType.NODES)) {
this.parseNodes(nodes);
} else if (this.match(TokenType.START)) {
this.consume(TokenType.COLON, "Expected ':' after 'начало'");
start = this.consume(TokenType.IDENTIFIER, "Expected start node identifier").value;
this.consume(TokenType.SEMICOLON, "Expected ';' after start node");
} else {
}
else if (this.match(TokenType.START)) {
this.consume(TokenType.COLON, 'Expected \':\' after \'начало\'');
start = this.consume(TokenType.IDENTIFIER, 'Expected start node identifier').value;
this.consume(TokenType.SEMICOLON, 'Expected \';\' after start node');
}
else {
throw new Error(`Unexpected token in graph: ${this.peek().type} at ${this.peek().line}:${this.peek().column}`);
}
}
this.consume(TokenType.RIGHT_BRACE, "Expected '}' after graph body");
this.consume(TokenType.RIGHT_BRACE, 'Expected \'}\' after graph body');
return {
type: 'Graph',
nodes,
start,
line: graphToken.line,
column: graphToken.column
column: graphToken.column,
};
}
private parseNodes(nodes: Record<string, NodeDefinition>): void {
this.consume(TokenType.LEFT_BRACE, "Expected '{' after 'узлы'");
this.consume(TokenType.LEFT_BRACE, 'Expected \'{\' after \'узлы\'');
while (!this.check(TokenType.RIGHT_BRACE) && !this.isAtEnd()) {
const nodeId = this.consume(TokenType.IDENTIFIER, "Expected node identifier").value;
this.consume(TokenType.COLON, "Expected ':' after node identifier");
this.consume(TokenType.LEFT_BRACE, "Expected '{' after node identifier");
const nodeId = this.consume(TokenType.IDENTIFIER, 'Expected node identifier').value;
this.consume(TokenType.COLON, 'Expected \':\' after node identifier');
this.consume(TokenType.LEFT_BRACE, 'Expected \'{\' after node identifier');
const node = this.parseNodeDefinition(nodeId);
nodes[nodeId] = node;
this.consume(TokenType.RIGHT_BRACE, "Expected '}' after node definition");
this.consume(TokenType.RIGHT_BRACE, 'Expected \'}\' after node definition');
}
this.consume(TokenType.RIGHT_BRACE, "Expected '}' after nodes");
this.consume(TokenType.RIGHT_BRACE, 'Expected \'}\' after nodes');
}
private parseNodeDefinition(id: string): NodeDefinition {
@@ -120,33 +110,41 @@ export class Parser {
while (!this.check(TokenType.RIGHT_BRACE) && !this.isAtEnd()) {
if (this.match(TokenType.TYPE)) {
this.consume(TokenType.COLON, "Expected ':' after 'тип'");
this.consume(TokenType.COLON, 'Expected \':\' after \'тип\'');
const typeToken = this.advance();
if (typeToken.type === TokenType.INITIAL) {
nodeType = 'начальный';
} else if (typeToken.type === TokenType.ACTION) {
}
else if (typeToken.type === TokenType.ACTION) {
nodeType = 'действие';
} else if (typeToken.type === TokenType.ENDING) {
}
else if (typeToken.type === TokenType.ENDING) {
nodeType = 'концовка';
} else {
}
else {
throw new Error(`Invalid node type: ${typeToken.value} at ${typeToken.line}:${typeToken.column}`);
}
this.consume(TokenType.SEMICOLON, "Expected ';' after node type");
} else if (this.match(TokenType.DESCRIPTION)) {
this.consume(TokenType.COLON, "Expected ':' after 'описание'");
description = this.consume(TokenType.STRING, "Expected description string").value;
this.consume(TokenType.SEMICOLON, "Expected ';' after description");
} else if (this.match(TokenType.TRANSITIONS)) {
this.consume(TokenType.COLON, "Expected ':' after 'переходы'");
this.consume(TokenType.SEMICOLON, 'Expected \';\' after node type');
}
else if (this.match(TokenType.DESCRIPTION)) {
this.consume(TokenType.COLON, 'Expected \':\' after \'описание\'');
description = this.consume(TokenType.STRING, 'Expected description string').value;
this.consume(TokenType.SEMICOLON, 'Expected \';\' after description');
}
else if (this.match(TokenType.TRANSITIONS)) {
this.consume(TokenType.COLON, 'Expected \':\' after \'переходы\'');
this.parseTransitions(transitions);
} else if (this.match(TokenType.OPTIONS)) {
this.consume(TokenType.COLON, "Expected ':' after 'варианты'");
}
else if (this.match(TokenType.OPTIONS)) {
this.consume(TokenType.COLON, 'Expected \':\' after \'варианты\'');
this.parseOptions(options);
} else if (this.match(TokenType.TITLE)) {
this.consume(TokenType.COLON, "Expected ':' after 'название'");
title = this.consume(TokenType.STRING, "Expected title string").value;
this.consume(TokenType.SEMICOLON, "Expected ';' after title");
} else {
}
else if (this.match(TokenType.TITLE)) {
this.consume(TokenType.COLON, 'Expected \':\' after \'название\'');
title = this.consume(TokenType.STRING, 'Expected title string').value;
this.consume(TokenType.SEMICOLON, 'Expected \';\' after title');
}
else {
throw new Error(`Unexpected token in node definition: ${this.peek().type} at ${this.peek().line}:${this.peek().column}`);
}
}
@@ -157,7 +155,7 @@ export class Parser {
nodeType,
description,
line: startToken.line,
column: startToken.column
column: startToken.column,
};
switch (nodeType) {
@@ -165,61 +163,61 @@ export class Parser {
return {
...baseNode,
type: 'InitialNode',
transitions
transitions,
} as InitialNode;
case 'действие':
return {
...baseNode,
type: 'ActionNode',
options
options,
} as ActionNode;
case 'концовка':
return {
...baseNode,
type: 'EndingNode',
title
title,
} as EndingNode;
}
}
private parseTransitions(transitions: string[]): void {
this.consume(TokenType.LEFT_BRACKET, "Expected '[' for transitions");
this.consume(TokenType.LEFT_BRACKET, 'Expected \'[\' for transitions');
if (!this.check(TokenType.RIGHT_BRACKET)) {
do {
const transition = this.consume(TokenType.IDENTIFIER, "Expected transition identifier").value;
const transition = this.consume(TokenType.IDENTIFIER, 'Expected transition identifier').value;
transitions.push(transition);
} while (this.match(TokenType.COMMA));
}
this.consume(TokenType.RIGHT_BRACKET, "Expected ']' after transitions");
this.consume(TokenType.SEMICOLON, "Expected ';' after transitions");
this.consume(TokenType.RIGHT_BRACKET, 'Expected \']\' after transitions');
this.consume(TokenType.SEMICOLON, 'Expected \';\' after transitions');
}
private parseOptions(options: OptionChoice[]): void {
this.consume(TokenType.LEFT_BRACKET, "Expected '[' for options");
this.consume(TokenType.LEFT_BRACKET, 'Expected \'[\' for options');
if (!this.check(TokenType.RIGHT_BRACKET)) {
do {
const optionToken = this.peek();
this.consume(TokenType.LEFT_PAREN, "Expected '(' for option");
const text = this.consume(TokenType.STRING, "Expected option text").value;
this.consume(TokenType.COMMA, "Expected ',' in option");
const target = this.consume(TokenType.IDENTIFIER, "Expected target identifier").value;
this.consume(TokenType.RIGHT_PAREN, "Expected ')' after option");
this.consume(TokenType.LEFT_PAREN, 'Expected \'(\' for option');
const text = this.consume(TokenType.STRING, 'Expected option text').value;
this.consume(TokenType.COMMA, 'Expected \',\' in option');
const target = this.consume(TokenType.IDENTIFIER, 'Expected target identifier').value;
this.consume(TokenType.RIGHT_PAREN, 'Expected \')\' after option');
options.push({
type: 'OptionChoice',
text,
target,
line: optionToken.line,
column: optionToken.column
column: optionToken.column,
});
} while (this.match(TokenType.COMMA));
}
this.consume(TokenType.RIGHT_BRACKET, "Expected ']' after options");
this.consume(TokenType.SEMICOLON, "Expected ';' after options");
this.consume(TokenType.RIGHT_BRACKET, 'Expected \']\' after options');
this.consume(TokenType.SEMICOLON, 'Expected \';\' after options');
}
private match(...types: TokenType[]): boolean {
@@ -233,12 +231,14 @@ export class Parser {
}
private check(type: TokenType): boolean {
if (this.isAtEnd()) return false;
if (this.isAtEnd())
return false;
return this.peek().type === type;
}
private advance(): Token {
if (!this.isAtEnd()) this.current++;
if (!this.isAtEnd())
this.current++;
return this.previous();
}
@@ -247,16 +247,17 @@ export class Parser {
}
private peek(): Token {
return this.tokens[this.current];
return this.tokens[this.current] || { type: TokenType.EOF, value: '', line: 0, column: 0, start: 0, end: 0 };
}
private previous(): Token {
return this.tokens[this.current - 1];
return this.tokens[this.current - 1] || { type: TokenType.EOF, value: '', line: 0, column: 0, start: 0, end: 0 };
}
private consume(type: TokenType, message: string): Token {
if (this.check(type)) return this.advance();
if (this.check(type))
return this.advance();
const current = this.peek();
throw new Error(`${message}. Got ${current.type} at ${current.line}:${current.column}`);
}