diff --git a/eslint.config.mjs b/eslint.config.mjs index a097343..e53eefa 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -11,9 +11,5 @@ export default antfu( }, }, typescript: true, - rules: { - 'unused-imports/no-unused-imports': 'error', - 'unused-imports/no-unused-vars': 'error', - } }, -); \ No newline at end of file +); diff --git a/package.json b/package.json index 5c79d11..93346bd 100644 --- a/package.json +++ b/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" - } } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ecddc50..334a237 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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) diff --git a/src/__tests__/integration.test.ts b/src/__tests__/integration.test.ts index a701652..d51be36 100644 --- a/src/__tests__/integration.test.ts +++ b/src/__tests__/integration.test.ts @@ -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('Пойти в дешёвую шашлычную'); }); }); diff --git a/src/__tests__/interpreter.test.ts b/src/__tests__/interpreter.test.ts index 3177e47..ed89827 100644 --- a/src/__tests__/interpreter.test.ts +++ b/src/__tests__/interpreter.test.ts @@ -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); }); }); diff --git a/src/__tests__/lexer.test.ts b/src/__tests__/lexer.test.ts index 44c99f4..b2f45d9 100644 --- a/src/__tests__/lexer.test.ts +++ b/src/__tests__/lexer.test.ts @@ -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); }); }); diff --git a/src/__tests__/parser.test.ts b/src/__tests__/parser.test.ts index 4c59781..59bd51d 100644 --- a/src/__tests__/parser.test.ts +++ b/src/__tests__/parser.test.ts @@ -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', () => { diff --git a/src/ast.ts b/src/ast.ts index 798d211..32b4ee3 100644 --- a/src/ast.ts +++ b/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', } /** diff --git a/src/cli.ts b/src/cli.ts index fe52791..e1e4296 100644 --- a/src/cli.ts +++ b/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 { 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 '); 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 '); 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 '); 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 { 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 { 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 { 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 { 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 { 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); diff --git a/src/index.ts b/src/index.ts index 66f375e..b61f827 100644 --- a/src/index.ts +++ b/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'; diff --git a/src/interpreter.ts b/src/interpreter.ts index 93c80ee..23758de 100644 --- a/src/interpreter.ts +++ b/src/interpreter.ts @@ -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(); - + this.findPaths(this.currentState.currentNode, [this.currentState.currentNode], paths, visited); - + return paths; } private findPaths(nodeId: string, currentPath: string[], allPaths: string[][], visited: Set): 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(); 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): 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); diff --git a/src/lexer.ts b/src/lexer.ts index 44c6950..1399cd6 100644 --- a/src/lexer.ts +++ b/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 { diff --git a/src/parser.ts b/src/parser.ts index 0d7bf4f..45fde7e 100644 --- a/src/parser.ts +++ b/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 = {}; 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): 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}`); } diff --git a/tsdown.config.ts b/tsdown.config.ts index 15f5de8..8b5271c 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -8,5 +8,6 @@ export default defineConfig([ { entry: 'src/cli.ts', noExternal: ['@clack/prompts'], + minify: true, }, -]); \ No newline at end of file +]);