mirror of
https://github.com/robonen/questlang.git
synced 2026-03-20 02:44:47 +00:00
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:
@@ -11,9 +11,5 @@ export default antfu(
|
||||
},
|
||||
},
|
||||
typescript: true,
|
||||
rules: {
|
||||
'unused-imports/no-unused-imports': 'error',
|
||||
'unused-imports/no-unused-vars': 'error',
|
||||
}
|
||||
},
|
||||
);
|
||||
);
|
||||
|
||||
19
package.json
19
package.json
@@ -1,15 +1,19 @@
|
||||
{
|
||||
"name": "questlang-interpreter",
|
||||
"version": "1.0.0",
|
||||
"license": "MIT",
|
||||
"description": "TypeScript interpreter for QuestLang programming language",
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
"questlang",
|
||||
"interpreter",
|
||||
"typescript",
|
||||
"language"
|
||||
],
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"bin": {
|
||||
"questlang": "dist/cli.js"
|
||||
},
|
||||
@@ -21,19 +25,14 @@
|
||||
"lint": "eslint ./src",
|
||||
"lint:fix": "eslint ./src --fix"
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^0.11.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@antfu/eslint-config": "^5.2.1",
|
||||
"@robonen/tsconfig": "^0.0.2",
|
||||
"tsdown": "^0.14.2",
|
||||
"typescript": "^5.9.2",
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^0.11.0"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -18,6 +18,9 @@ importers:
|
||||
'@robonen/tsconfig':
|
||||
specifier: ^0.0.2
|
||||
version: 0.0.2
|
||||
eslint:
|
||||
specifier: ^9.34.0
|
||||
version: 9.34.0(jiti@2.5.1)
|
||||
tsdown:
|
||||
specifier: ^0.14.2
|
||||
version: 0.14.2(typescript@5.9.2)
|
||||
|
||||
@@ -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('Пойти в дешёвую шашлычную');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
10
src/ast.ts
10
src/ast.ts
@@ -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',
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
130
src/cli.ts
130
src/cli.ts
@@ -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);
|
||||
|
||||
17
src/index.ts
17
src/index.ts
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
|
||||
39
src/lexer.ts
39
src/lexer.ts
@@ -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 {
|
||||
|
||||
167
src/parser.ts
167
src/parser.ts
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -8,5 +8,6 @@ export default defineConfig([
|
||||
{
|
||||
entry: 'src/cli.ts',
|
||||
noExternal: ['@clack/prompts'],
|
||||
minify: true,
|
||||
},
|
||||
]);
|
||||
]);
|
||||
|
||||
Reference in New Issue
Block a user