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

@@ -11,9 +11,5 @@ export default antfu(
}, },
}, },
typescript: true, typescript: true,
rules: {
'unused-imports/no-unused-imports': 'error',
'unused-imports/no-unused-vars': 'error',
}
}, },
); );

View File

@@ -1,15 +1,19 @@
{ {
"name": "questlang-interpreter", "name": "questlang-interpreter",
"version": "1.0.0", "version": "1.0.0",
"license": "MIT",
"description": "TypeScript interpreter for QuestLang programming language", "description": "TypeScript interpreter for QuestLang programming language",
"license": "MIT",
"keywords": [ "keywords": [
"questlang", "questlang",
"interpreter", "interpreter",
"typescript", "typescript",
"language" "language"
], ],
"type": "module", "exports": {
".": {
"import": "./dist/index.js"
}
},
"bin": { "bin": {
"questlang": "dist/cli.js" "questlang": "dist/cli.js"
}, },
@@ -21,19 +25,14 @@
"lint": "eslint ./src", "lint": "eslint ./src",
"lint:fix": "eslint ./src --fix" "lint:fix": "eslint ./src --fix"
}, },
"dependencies": {
"@clack/prompts": "^0.11.0"
},
"devDependencies": { "devDependencies": {
"@antfu/eslint-config": "^5.2.1", "@antfu/eslint-config": "^5.2.1",
"@robonen/tsconfig": "^0.0.2", "@robonen/tsconfig": "^0.0.2",
"tsdown": "^0.14.2", "tsdown": "^0.14.2",
"typescript": "^5.9.2", "typescript": "^5.9.2",
"vitest": "^3.2.4" "vitest": "^3.2.4"
},
"dependencies": {
"@clack/prompts": "^0.11.0"
},
"exports": {
".": {
"import": "./dist/index.js"
}
} }
} }

3
pnpm-lock.yaml generated
View File

@@ -18,6 +18,9 @@ importers:
'@robonen/tsconfig': '@robonen/tsconfig':
specifier: ^0.0.2 specifier: ^0.0.2
version: 0.0.2 version: 0.0.2
eslint:
specifier: ^9.34.0
version: 9.34.0(jiti@2.5.1)
tsdown: tsdown:
specifier: ^0.14.2 specifier: ^0.14.2
version: 0.14.2(typescript@5.9.2) version: 0.14.2(typescript@5.9.2)

View File

@@ -138,6 +138,6 @@ describe('questLang Integration', () => {
interpreter.moveToNode('выбор_пути'); interpreter.moveToNode('выбор_пути');
const choices = interpreter.getAvailableChoices(); const choices = interpreter.getAvailableChoices();
expect(choices).toHaveLength(3); 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); expect(paths.length).toBeGreaterThan(0);
// Should have at least two paths (налево->победа and направо) // Should have at least two paths (налево->победа and направо)
expect(paths.some(path => path.includes('победа'))).toBe(true); expect(paths.some((path: string) => path.includes('победа'))).toBe(true);
expect(paths.some(path => 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 { describe, expect, it } from 'vitest';
import { Lexer } from '../lexer';
import { TokenType } from '..'; import { TokenType } from '..';
import { Lexer } from '../lexer';
describe('lexer', () => { describe('lexer', () => {
it('should tokenize quest keywords', () => { it('should tokenize quest keywords', () => {
@@ -8,19 +8,19 @@ describe('lexer', () => {
const lexer = new Lexer(source); const lexer = new Lexer(source);
const tokens = lexer.tokenize(); const tokens = lexer.tokenize();
expect(tokens[0].type).toBe(TokenType.QUEST); expect(tokens[0]?.type).toBe(TokenType.QUEST);
expect(tokens[0].value).toBe('квест'); expect(tokens[0]?.value).toBe('квест');
expect(tokens[1].type).toBe(TokenType.IDENTIFIER); expect(tokens[1]?.type).toBe(TokenType.IDENTIFIER);
expect(tokens[1].value).toBe('Тест'); 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]?.type).toBe(TokenType.GOAL);
expect(tokens[3].value).toBe('цель'); expect(tokens[3]?.value).toBe('цель');
expect(tokens[4].type).toBe(TokenType.STRING); expect(tokens[4]?.type).toBe(TokenType.STRING);
expect(tokens[4].value).toBe('Описание'); expect(tokens[4]?.value).toBe('Описание');
}); });
it('should tokenize strings correctly', () => { it('should tokenize strings correctly', () => {
@@ -28,8 +28,8 @@ describe('lexer', () => {
const lexer = new Lexer(source); const lexer = new Lexer(source);
const tokens = lexer.tokenize(); const tokens = lexer.tokenize();
expect(tokens[0].type).toBe(TokenType.STRING); expect(tokens[0]?.type).toBe(TokenType.STRING);
expect(tokens[0].value).toBe('Тестовая строка с пробелами'); expect(tokens[0]?.value).toBe('Тестовая строка с пробелами');
}); });
it('should tokenize numbers', () => { it('should tokenize numbers', () => {
@@ -37,11 +37,11 @@ describe('lexer', () => {
const lexer = new Lexer(source); const lexer = new Lexer(source);
const tokens = lexer.tokenize(); const tokens = lexer.tokenize();
expect(tokens[0].type).toBe(TokenType.NUMBER); expect(tokens[0]?.type).toBe(TokenType.NUMBER);
expect(tokens[0].value).toBe('42'); expect(tokens[0]?.value).toBe('42');
expect(tokens[1].type).toBe(TokenType.NUMBER); expect(tokens[1]?.type).toBe(TokenType.NUMBER);
expect(tokens[1].value).toBe('3.14'); expect(tokens[1]?.value).toBe('3.14');
}); });
it('should handle comments', () => { it('should handle comments', () => {
@@ -49,10 +49,10 @@ describe('lexer', () => {
const lexer = new Lexer(source); const lexer = new Lexer(source);
const tokens = lexer.tokenize(); const tokens = lexer.tokenize();
expect(tokens[0].type).toBe(TokenType.COMMENT); expect(tokens[0]?.type).toBe(TokenType.COMMENT);
expect(tokens[0].value).toBe('// это комментарий'); 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', () => { it('should track line and column numbers', () => {
@@ -60,11 +60,11 @@ describe('lexer', () => {
const lexer = new Lexer(source); const lexer = new Lexer(source);
const tokens = lexer.tokenize(); const tokens = lexer.tokenize();
expect(tokens[0].line).toBe(1); expect(tokens[0]?.line).toBe(1);
expect(tokens[0].column).toBe(1); expect(tokens[0]?.column).toBe(1);
expect(tokens[1].line).toBe(2); expect(tokens[1]?.line).toBe(2);
expect(tokens[1].column).toBe(1); expect(tokens[1]?.column).toBe(1);
}); });
it('should handle all symbols', () => { it('should handle all symbols', () => {
@@ -86,7 +86,7 @@ describe('lexer', () => {
]; ];
expectedTypes.forEach((expectedType, index) => { 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; const nodes = ast.graph.nodes;
expect(Object.keys(nodes)).toHaveLength(4); expect(Object.keys(nodes)).toHaveLength(4);
expect(nodes.старт.nodeType).toBe('начальный'); expect(nodes.старт?.nodeType).toBe('начальный');
expect(nodes.действие1.nodeType).toBe('действие'); expect(nodes.действие1?.nodeType).toBe('действие');
expect(nodes.конец1.nodeType).toBe('концовка'); expect(nodes.конец1?.nodeType).toBe('концовка');
expect(nodes.конец2.nodeType).toBe('концовка'); expect(nodes.конец2?.nodeType).toBe('концовка');
}); });
it('should parse action node with options', () => { it('should parse action node with options', () => {

View File

@@ -41,7 +41,7 @@ export enum TokenType {
EOF = 'EOF', EOF = 'EOF',
NEWLINE = 'NEWLINE', NEWLINE = 'NEWLINE',
COMMENT = 'COMMENT', COMMENT = 'COMMENT',
WHITESPACE = 'WHITESPACE' WHITESPACE = 'WHITESPACE',
} }
/** /**

View File

@@ -1,9 +1,11 @@
#!/usr/bin/env node #!/usr/bin/env node
import type { QuestInterpreter } from '.';
import type { InitialNode, NodeDefinition } from './ast.js';
import fs from 'node:fs'; import fs from 'node:fs';
import process from 'node:process';
import * as p from '@clack/prompts'; import * as p from '@clack/prompts';
import { QuestLang, type QuestInterpreter } from '.'; import { QuestLang } from '.';
import { type NodeDefinition, type InitialNode } from './ast';
/** /**
* Beautiful command-line interface for QuestLang using clack * Beautiful command-line interface for QuestLang using clack
@@ -32,7 +34,7 @@ class ClackCLI {
p.outro('❌ Usage: questlang-clack play <file.ql>'); p.outro('❌ Usage: questlang-clack play <file.ql>');
process.exit(1); process.exit(1);
} }
await this.playQuest(args[1]); await this.playQuest(args[1]!);
break; break;
case 'validate': case 'validate':
@@ -40,7 +42,7 @@ class ClackCLI {
p.outro('❌ Usage: questlang-clack validate <file.ql>'); p.outro('❌ Usage: questlang-clack validate <file.ql>');
process.exit(1); process.exit(1);
} }
await this.validateQuest(args[1]); await this.validateQuest(args[1]!);
break; break;
case 'analyze': case 'analyze':
@@ -48,7 +50,7 @@ class ClackCLI {
p.outro('❌ Usage: questlang-clack analyze <file.ql>'); p.outro('❌ Usage: questlang-clack analyze <file.ql>');
process.exit(1); process.exit(1);
} }
await this.analyzeQuest(args[1]); await this.analyzeQuest(args[1]!);
break; break;
default: default:
@@ -107,7 +109,8 @@ class ClackCLI {
return fs.readdirSync('.') return fs.readdirSync('.')
.filter(file => file.endsWith('.ql')) .filter(file => file.endsWith('.ql'))
.sort(); .sort();
} catch { }
catch {
return []; return [];
} }
} }
@@ -138,8 +141,8 @@ class ClackCLI {
p.note(`📖 ${questInfo.goal}`, `🎮 ${questInfo.name}`); p.note(`📖 ${questInfo.goal}`, `🎮 ${questInfo.name}`);
await this.gameLoop(interpreter); await this.gameLoop(interpreter);
}
} catch (error) { catch (error) {
spinner.stop('❌ Error loading quest'); spinner.stop('❌ Error loading quest');
p.log.error(error instanceof Error ? error.message : String(error)); p.log.error(error instanceof Error ? error.message : String(error));
p.outro('Failed to start quest'); p.outro('Failed to start quest');
@@ -166,8 +169,8 @@ class ClackCLI {
message: 'What do you want to do?', message: 'What do you want to do?',
options: choices.map((choice, index) => ({ options: choices.map((choice, index) => ({
value: index, value: index,
label: choice.text label: choice.text,
})) })),
}); });
if (p.isCancel(choice)) { if (p.isCancel(choice)) {
@@ -181,16 +184,23 @@ class ClackCLI {
p.log.error(`Error: ${result.error}`); p.log.error(`Error: ${result.error}`);
break; break;
} }
} else if (currentNode.nodeType === 'начальный') { }
else if (currentNode.nodeType === 'начальный') {
// Auto-advance from initial nodes to first transition // Auto-advance from initial nodes to first transition
const initialNode = currentNode as InitialNode; const initialNode = currentNode as InitialNode;
if (initialNode.transitions && initialNode.transitions.length > 0) { 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) { if (!result.success) {
p.log.error(`Error: ${result.error}`); p.log.error(`Error: ${result.error}`);
break; break;
} }
} else { }
else {
p.log.error('Initial node has no transitions'); p.log.error('Initial node has no transitions');
break; break;
} }
@@ -203,7 +213,7 @@ class ClackCLI {
const finalNode = interpreter.getCurrentNode(); const finalNode = interpreter.getCurrentNode();
p.note( p.note(
finalNode?.description || 'Quest completed', finalNode?.description || 'Quest completed',
`🏆 ${state.endingTitle}` `🏆 ${state.endingTitle}`,
); );
const playAgain = await p.confirm({ const playAgain = await p.confirm({
@@ -213,7 +223,8 @@ class ClackCLI {
if (!p.isCancel(playAgain) && playAgain) { if (!p.isCancel(playAgain) && playAgain) {
interpreter.reset(); interpreter.reset();
await this.gameLoop(interpreter); await this.gameLoop(interpreter);
} else { }
else {
p.outro('Thanks for playing! 🎉'); p.outro('Thanks for playing! 🎉');
} }
} }
@@ -232,7 +243,8 @@ class ClackCLI {
spinner.stop('✅ Validation completed'); spinner.stop('✅ Validation completed');
p.log.success('Quest is valid!'); p.log.success('Quest is valid!');
p.outro('🎉 No issues found'); p.outro('🎉 No issues found');
} else { }
else {
spinner.stop('❌ Validation failed'); spinner.stop('❌ Validation failed');
p.log.error('Validation errors:'); p.log.error('Validation errors:');
@@ -240,7 +252,8 @@ class ClackCLI {
p.outro('Fix the errors and try again'); p.outro('Fix the errors and try again');
process.exit(1); process.exit(1);
} }
} catch (error) { }
catch (error) {
spinner.stop('❌ Error during validation'); spinner.stop('❌ Error during validation');
p.log.error(error instanceof Error ? error.message : String(error)); p.log.error(error instanceof Error ? error.message : String(error));
p.outro('Validation failed'); p.outro('Validation failed');
@@ -266,12 +279,12 @@ class ClackCLI {
const endingNodes = nodes.filter(n => n.nodeType === 'концовка'); const endingNodes = nodes.filter(n => n.nodeType === 'концовка');
p.note( p.note(
`📊 Quest Analysis Results\n\n` + `📊 Quest Analysis Results\n\n`
`Total nodes: ${nodes.length}\n` + + `Total nodes: ${nodes.length}\n`
` • Initial nodes: ${initialNodes.length}\n` + + ` • Initial nodes: ${initialNodes.length}\n`
` • Action nodes: ${actionNodes.length}\n` + + ` • Action nodes: ${actionNodes.length}\n`
` • Ending nodes: ${endingNodes.length}`, + ` • Ending nodes: ${endingNodes.length}`,
`📖 ${questInfo.name}` `📖 ${questInfo.name}`,
); );
// Analyze paths // Analyze paths
@@ -295,13 +308,14 @@ class ClackCLI {
if (validation.isValid) { if (validation.isValid) {
p.log.success('✅ Quest structure is valid'); p.log.success('✅ Quest structure is valid');
p.outro('🎉 Analysis completed successfully'); p.outro('🎉 Analysis completed successfully');
} else { }
else {
p.log.warn('⚠️ Quest has validation issues:'); p.log.warn('⚠️ Quest has validation issues:');
validation.errors.forEach(error => p.log.warn(`${error}`)); validation.errors.forEach(error => p.log.warn(`${error}`));
p.outro('Consider fixing these issues'); p.outro('Consider fixing these issues');
} }
}
} catch (error) { catch (error) {
spinner.stop('❌ Error during analysis'); spinner.stop('❌ Error during analysis');
p.log.error(error instanceof Error ? error.message : String(error)); p.log.error(error instanceof Error ? error.message : String(error));
p.outro('Analysis failed'); p.outro('Analysis failed');
@@ -320,7 +334,7 @@ class ClackCLI {
const cli = new ClackCLI(); const cli = new ClackCLI();
cli.run().catch(error => { cli.run().catch((error) => {
p.log.error(`Unexpected error: ${error}`); p.log.error(`Unexpected error: ${error}`);
p.outro('❌ CLI crashed'); p.outro('❌ CLI crashed');
process.exit(1); process.exit(1);

View File

@@ -1,7 +1,7 @@
import type { QuestProgram } from './ast';
import { QuestInterpreter } from './interpreter';
import { Lexer } from './lexer'; import { Lexer } from './lexer';
import { Parser } from './parser'; import { Parser } from './parser';
import { QuestInterpreter } from './interpreter';
import type { QuestProgram } from './ast';
/** /**
* Main QuestLang processor * Main QuestLang processor
@@ -33,18 +33,17 @@ export class QuestLang {
try { try {
const interpreter = this.interpret(source); const interpreter = this.interpret(source);
return interpreter.validate(); return interpreter.validate();
} catch (error) { }
catch (error) {
return { return {
isValid: false, 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 { Lexer } from './lexer';
export { Parser } from './parser'; export { Parser } from './parser';
export { QuestInterpreter } from './interpreter';
export * from './ast';
export * from './types';

View File

@@ -1,11 +1,10 @@
import type { import type {
QuestProgram,
GraphNode,
NodeDefinition,
InitialNode,
ActionNode, ActionNode,
EndingNode, EndingNode,
OptionChoice InitialNode,
NodeDefinition,
OptionChoice,
QuestProgram,
} from './ast'; } from './ast';
/** /**
@@ -31,9 +30,9 @@ export interface ExecutionResult {
* Visitor interface for quest nodes * Visitor interface for quest nodes
*/ */
export interface QuestVisitor { export interface QuestVisitor {
visitInitialNode(node: InitialNode, state: QuestState): void; visitInitialNode: (node: InitialNode, state: QuestState) => void;
visitActionNode(node: ActionNode, state: QuestState): void; visitActionNode: (node: ActionNode, state: QuestState) => void;
visitEndingNode(node: EndingNode, state: QuestState): void; visitEndingNode: (node: EndingNode, state: QuestState) => void;
} }
/** /**
@@ -48,7 +47,7 @@ export class QuestInterpreter {
this.currentState = { this.currentState = {
currentNode: program.graph.start, currentNode: program.graph.start,
history: [], history: [],
isComplete: false isComplete: false,
}; };
} }
@@ -66,7 +65,7 @@ export class QuestInterpreter {
return { return {
name: this.program.name, name: this.program.name,
goal: this.program.goal, goal: this.program.goal,
isComplete: this.currentState.isComplete isComplete: this.currentState.isComplete,
}; };
} }
@@ -106,7 +105,7 @@ export class QuestInterpreter {
return { return {
success: false, success: false,
newState: this.currentState, 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 { return {
success: false, success: false,
newState: this.currentState, 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 { return {
success: false, success: false,
newState: this.currentState, 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]; const choice = choices[choiceIndex];
if (!choice) {
return {
success: false,
newState: this.currentState,
error: `Choice at index ${choiceIndex} is undefined`,
};
}
return this.moveToNode(choice.target); return this.moveToNode(choice.target);
} }
@@ -143,7 +149,7 @@ export class QuestInterpreter {
return { return {
success: false, success: false,
newState: this.currentState, newState: this.currentState,
error: `Target node '${nodeId}' not found` error: `Target node '${nodeId}' not found`,
}; };
} }
@@ -152,14 +158,14 @@ export class QuestInterpreter {
currentNode: nodeId, currentNode: nodeId,
history: [...this.currentState.history, this.currentState.currentNode], history: [...this.currentState.history, this.currentState.currentNode],
isComplete: targetNode.nodeType === 'концовка', isComplete: targetNode.nodeType === 'концовка',
endingTitle: targetNode.nodeType === 'концовка' ? (targetNode as EndingNode).title : undefined endingTitle: targetNode.nodeType === 'концовка' ? (targetNode as EndingNode).title : undefined,
}; };
this.currentState = newState; this.currentState = newState;
return { return {
success: true, success: true,
newState: { ...newState } newState: { ...newState },
}; };
} }
@@ -170,7 +176,7 @@ export class QuestInterpreter {
this.currentState = { this.currentState = {
currentNode: this.program.graph.start, currentNode: this.program.graph.start,
history: [], history: [],
isComplete: false isComplete: false,
}; };
} }
@@ -205,7 +211,8 @@ export class QuestInterpreter {
for (const option of actionNode.options) { for (const option of actionNode.options) {
this.findPaths(option.target, [...currentPath, option.target], allPaths, new Set(visited)); this.findPaths(option.target, [...currentPath, option.target], allPaths, new Set(visited));
} }
} else if (node.nodeType === 'начальный') { }
else if (node.nodeType === 'начальный') {
const initialNode = node as InitialNode; const initialNode = node as InitialNode;
for (const transition of initialNode.transitions) { for (const transition of initialNode.transitions) {
this.findPaths(transition, [...currentPath, transition], allPaths, new Set(visited)); 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}'`); errors.push(`Node '${nodeId}' references non-existent target '${option.target}'`);
} }
} }
} else if (node.nodeType === 'начальный') { }
else if (node.nodeType === 'начальный') {
const initialNode = node as InitialNode; const initialNode = node as InitialNode;
for (const transition of initialNode.transitions) { for (const transition of initialNode.transitions) {
if (!this.program.graph.nodes[transition]) { if (!this.program.graph.nodes[transition]) {
@@ -256,15 +264,17 @@ export class QuestInterpreter {
return { return {
isValid: errors.length === 0, isValid: errors.length === 0,
errors errors,
}; };
} }
private findReachableNodes(nodeId: string, reachable: Set<string>): void { 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]; const node = this.program.graph.nodes[nodeId];
if (!node) return; if (!node)
return;
reachable.add(nodeId); reachable.add(nodeId);
@@ -273,7 +283,8 @@ export class QuestInterpreter {
for (const option of actionNode.options) { for (const option of actionNode.options) {
this.findReachableNodes(option.target, reachable); this.findReachableNodes(option.target, reachable);
} }
} else if (node.nodeType === 'начальный') { }
else if (node.nodeType === 'начальный') {
const initialNode = node as InitialNode; const initialNode = node as InitialNode;
for (const transition of initialNode.transitions) { for (const transition of initialNode.transitions) {
this.findReachableNodes(transition, reachable); this.findReachableNodes(transition, reachable);

View File

@@ -1,5 +1,5 @@
import type { Token } from './ast';
import { TokenType } from './ast'; import { TokenType } from './ast';
import type { Token, Position } from './ast';
/** /**
* Lexical analyzer for QuestLang * Lexical analyzer for QuestLang
@@ -26,7 +26,7 @@ export class Lexer {
['название', TokenType.TITLE], ['название', TokenType.TITLE],
['начальный', TokenType.INITIAL], ['начальный', TokenType.INITIAL],
['действие', TokenType.ACTION], ['действие', TokenType.ACTION],
['концовка', TokenType.ENDING] ['концовка', TokenType.ENDING],
]); ]);
constructor(source: string) { constructor(source: string) {
@@ -100,7 +100,8 @@ export class Lexer {
case '/': case '/':
if (this.match('/')) { if (this.match('/')) {
this.scanComment(start, startLine, startColumn); this.scanComment(start, startLine, startColumn);
} else { }
else {
throw new Error(`Unexpected character: ${c} at ${startLine}:${startColumn}`); throw new Error(`Unexpected character: ${c} at ${startLine}:${startColumn}`);
} }
break; break;
@@ -110,9 +111,11 @@ export class Lexer {
default: default:
if (this.isDigit(c)) { if (this.isDigit(c)) {
this.scanNumber(start, startLine, startColumn); this.scanNumber(start, startLine, startColumn);
} else if (this.isAlpha(c)) { }
else if (this.isAlpha(c)) {
this.scanIdentifier(start, startLine, startColumn); this.scanIdentifier(start, startLine, startColumn);
} else { }
else {
throw new Error(`Unexpected character: ${c} at ${startLine}:${startColumn}`); throw new Error(`Unexpected character: ${c} at ${startLine}:${startColumn}`);
} }
break; break;
@@ -182,7 +185,7 @@ export class Lexer {
line: line || this.line, line: line || this.line,
column: column || this.column, column: column || this.column,
start: start || this.position, start: start || this.position,
end: this.position end: this.position,
}); });
} }
@@ -194,20 +197,24 @@ export class Lexer {
} }
private match(expected: string): boolean { private match(expected: string): boolean {
if (this.isAtEnd()) return false; if (this.isAtEnd())
if (this.source.charAt(this.position) !== expected) return false; return false;
if (this.source.charAt(this.position) !== expected)
return false;
this.position++; this.position++;
this.column++; this.column++;
return true; return true;
} }
private peek(): string { private peek(): string {
if (this.isAtEnd()) return '\0'; if (this.isAtEnd())
return '\0';
return this.source.charAt(this.position); return this.source.charAt(this.position);
} }
private peekNext(): string { 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); return this.source.charAt(this.position + 1);
} }
@@ -220,12 +227,12 @@ export class Lexer {
} }
private isAlpha(c: string): boolean { private isAlpha(c: string): boolean {
return (c >= 'a' && c <= 'z') || return (c >= 'a' && c <= 'z')
(c >= 'A' && c <= 'Z') || || (c >= 'A' && c <= 'Z')
(c >= 'а' && c <= 'я') || || (c >= 'а' && c <= 'я')
(c >= 'А' && c <= 'Я') || || (c >= 'А' && c <= 'Я')
c === 'ё' || c === 'Ё' || || c === 'ё' || c === 'Ё'
c === '_'; || c === '_';
} }
private isAlphaNumeric(c: string): boolean { 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 { 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 * Parser for QuestLang
@@ -23,8 +11,8 @@ export class Parser {
constructor(tokens: Token[]) { constructor(tokens: Token[]) {
// Filter out comments and whitespace // Filter out comments and whitespace
this.tokens = tokens.filter(token => this.tokens = tokens.filter(token =>
token.type !== TokenType.COMMENT && token.type !== TokenType.COMMENT
token.type !== TokenType.WHITESPACE && token.type !== TokenType.WHITESPACE,
); );
} }
@@ -36,15 +24,15 @@ export class Parser {
} }
private parseQuest(): QuestProgram { private parseQuest(): QuestProgram {
const questToken = this.consume(TokenType.QUEST, "Expected 'квест'"); const questToken = this.consume(TokenType.QUEST, 'Expected \'квест\'');
const name = this.consume(TokenType.IDENTIFIER, "Expected quest name").value; const name = this.consume(TokenType.IDENTIFIER, 'Expected quest name').value;
this.consume(TokenType.SEMICOLON, "Expected ';' after quest name"); this.consume(TokenType.SEMICOLON, 'Expected \';\' after quest name');
const goal = this.parseGoal(); const goal = this.parseGoal();
const graph = this.parseGraph(); const graph = this.parseGraph();
this.consume(TokenType.END, "Expected 'конец'"); this.consume(TokenType.END, 'Expected \'конец\'');
this.consume(TokenType.SEMICOLON, "Expected ';' after 'конец'"); this.consume(TokenType.SEMICOLON, 'Expected \';\' after \'конец\'');
return { return {
type: 'QuestProgram', type: 'QuestProgram',
@@ -52,20 +40,20 @@ export class Parser {
goal, goal,
graph, graph,
line: questToken.line, line: questToken.line,
column: questToken.column column: questToken.column,
}; };
} }
private parseGoal(): string { private parseGoal(): string {
this.consume(TokenType.GOAL, "Expected 'цель'"); this.consume(TokenType.GOAL, 'Expected \'цель\'');
const goalValue = this.consume(TokenType.STRING, "Expected goal description").value; const goalValue = this.consume(TokenType.STRING, 'Expected goal description').value;
this.consume(TokenType.SEMICOLON, "Expected ';' after goal"); this.consume(TokenType.SEMICOLON, 'Expected \';\' after goal');
return goalValue; return goalValue;
} }
private parseGraph(): GraphNode { private parseGraph(): GraphNode {
const graphToken = this.consume(TokenType.GRAPH, "Expected 'граф'"); const graphToken = this.consume(TokenType.GRAPH, 'Expected \'граф\'');
this.consume(TokenType.LEFT_BRACE, "Expected '{' after 'граф'"); this.consume(TokenType.LEFT_BRACE, 'Expected \'{\' after \'граф\'');
const nodes: Record<string, NodeDefinition> = {}; const nodes: Record<string, NodeDefinition> = {};
let start = ''; let start = '';
@@ -73,41 +61,43 @@ export class Parser {
while (!this.check(TokenType.RIGHT_BRACE) && !this.isAtEnd()) { while (!this.check(TokenType.RIGHT_BRACE) && !this.isAtEnd()) {
if (this.match(TokenType.NODES)) { if (this.match(TokenType.NODES)) {
this.parseNodes(nodes); this.parseNodes(nodes);
} else if (this.match(TokenType.START)) { }
this.consume(TokenType.COLON, "Expected ':' after 'начало'"); else if (this.match(TokenType.START)) {
start = this.consume(TokenType.IDENTIFIER, "Expected start node identifier").value; this.consume(TokenType.COLON, 'Expected \':\' after \'начало\'');
this.consume(TokenType.SEMICOLON, "Expected ';' after start node"); start = this.consume(TokenType.IDENTIFIER, 'Expected start node identifier').value;
} else { 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}`); 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 { return {
type: 'Graph', type: 'Graph',
nodes, nodes,
start, start,
line: graphToken.line, line: graphToken.line,
column: graphToken.column column: graphToken.column,
}; };
} }
private parseNodes(nodes: Record<string, NodeDefinition>): void { 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()) { while (!this.check(TokenType.RIGHT_BRACE) && !this.isAtEnd()) {
const nodeId = this.consume(TokenType.IDENTIFIER, "Expected node identifier").value; const nodeId = this.consume(TokenType.IDENTIFIER, 'Expected node identifier').value;
this.consume(TokenType.COLON, "Expected ':' after node identifier"); this.consume(TokenType.COLON, 'Expected \':\' after node identifier');
this.consume(TokenType.LEFT_BRACE, "Expected '{' after node identifier"); this.consume(TokenType.LEFT_BRACE, 'Expected \'{\' after node identifier');
const node = this.parseNodeDefinition(nodeId); const node = this.parseNodeDefinition(nodeId);
nodes[nodeId] = node; 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 { private parseNodeDefinition(id: string): NodeDefinition {
@@ -120,33 +110,41 @@ export class Parser {
while (!this.check(TokenType.RIGHT_BRACE) && !this.isAtEnd()) { while (!this.check(TokenType.RIGHT_BRACE) && !this.isAtEnd()) {
if (this.match(TokenType.TYPE)) { if (this.match(TokenType.TYPE)) {
this.consume(TokenType.COLON, "Expected ':' after 'тип'"); this.consume(TokenType.COLON, 'Expected \':\' after \'тип\'');
const typeToken = this.advance(); const typeToken = this.advance();
if (typeToken.type === TokenType.INITIAL) { if (typeToken.type === TokenType.INITIAL) {
nodeType = 'начальный'; nodeType = 'начальный';
} else if (typeToken.type === TokenType.ACTION) { }
else if (typeToken.type === TokenType.ACTION) {
nodeType = 'действие'; nodeType = 'действие';
} else if (typeToken.type === TokenType.ENDING) { }
else if (typeToken.type === TokenType.ENDING) {
nodeType = 'концовка'; nodeType = 'концовка';
} else { }
else {
throw new Error(`Invalid node type: ${typeToken.value} at ${typeToken.line}:${typeToken.column}`); throw new Error(`Invalid node type: ${typeToken.value} at ${typeToken.line}:${typeToken.column}`);
} }
this.consume(TokenType.SEMICOLON, "Expected ';' after node type"); this.consume(TokenType.SEMICOLON, 'Expected \';\' after node type');
} else if (this.match(TokenType.DESCRIPTION)) { }
this.consume(TokenType.COLON, "Expected ':' after 'описание'"); else if (this.match(TokenType.DESCRIPTION)) {
description = this.consume(TokenType.STRING, "Expected description string").value; this.consume(TokenType.COLON, 'Expected \':\' after \'описание\'');
this.consume(TokenType.SEMICOLON, "Expected ';' after description"); description = this.consume(TokenType.STRING, 'Expected description string').value;
} else if (this.match(TokenType.TRANSITIONS)) { this.consume(TokenType.SEMICOLON, 'Expected \';\' after description');
this.consume(TokenType.COLON, "Expected ':' after 'переходы'"); }
else if (this.match(TokenType.TRANSITIONS)) {
this.consume(TokenType.COLON, 'Expected \':\' after \'переходы\'');
this.parseTransitions(transitions); 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); this.parseOptions(options);
} else if (this.match(TokenType.TITLE)) { }
this.consume(TokenType.COLON, "Expected ':' after 'название'"); else if (this.match(TokenType.TITLE)) {
title = this.consume(TokenType.STRING, "Expected title string").value; this.consume(TokenType.COLON, 'Expected \':\' after \'название\'');
this.consume(TokenType.SEMICOLON, "Expected ';' after title"); title = this.consume(TokenType.STRING, 'Expected title string').value;
} else { 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}`); 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, nodeType,
description, description,
line: startToken.line, line: startToken.line,
column: startToken.column column: startToken.column,
}; };
switch (nodeType) { switch (nodeType) {
@@ -165,61 +163,61 @@ export class Parser {
return { return {
...baseNode, ...baseNode,
type: 'InitialNode', type: 'InitialNode',
transitions transitions,
} as InitialNode; } as InitialNode;
case 'действие': case 'действие':
return { return {
...baseNode, ...baseNode,
type: 'ActionNode', type: 'ActionNode',
options options,
} as ActionNode; } as ActionNode;
case 'концовка': case 'концовка':
return { return {
...baseNode, ...baseNode,
type: 'EndingNode', type: 'EndingNode',
title title,
} as EndingNode; } as EndingNode;
} }
} }
private parseTransitions(transitions: string[]): void { 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)) { if (!this.check(TokenType.RIGHT_BRACKET)) {
do { do {
const transition = this.consume(TokenType.IDENTIFIER, "Expected transition identifier").value; const transition = this.consume(TokenType.IDENTIFIER, 'Expected transition identifier').value;
transitions.push(transition); transitions.push(transition);
} while (this.match(TokenType.COMMA)); } while (this.match(TokenType.COMMA));
} }
this.consume(TokenType.RIGHT_BRACKET, "Expected ']' after transitions"); this.consume(TokenType.RIGHT_BRACKET, 'Expected \']\' after transitions');
this.consume(TokenType.SEMICOLON, "Expected ';' after transitions"); this.consume(TokenType.SEMICOLON, 'Expected \';\' after transitions');
} }
private parseOptions(options: OptionChoice[]): void { 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)) { if (!this.check(TokenType.RIGHT_BRACKET)) {
do { do {
const optionToken = this.peek(); const optionToken = this.peek();
this.consume(TokenType.LEFT_PAREN, "Expected '(' for option"); this.consume(TokenType.LEFT_PAREN, 'Expected \'(\' for option');
const text = this.consume(TokenType.STRING, "Expected option text").value; const text = this.consume(TokenType.STRING, 'Expected option text').value;
this.consume(TokenType.COMMA, "Expected ',' in option"); this.consume(TokenType.COMMA, 'Expected \',\' in option');
const target = this.consume(TokenType.IDENTIFIER, "Expected target identifier").value; const target = this.consume(TokenType.IDENTIFIER, 'Expected target identifier').value;
this.consume(TokenType.RIGHT_PAREN, "Expected ')' after option"); this.consume(TokenType.RIGHT_PAREN, 'Expected \')\' after option');
options.push({ options.push({
type: 'OptionChoice', type: 'OptionChoice',
text, text,
target, target,
line: optionToken.line, line: optionToken.line,
column: optionToken.column column: optionToken.column,
}); });
} while (this.match(TokenType.COMMA)); } while (this.match(TokenType.COMMA));
} }
this.consume(TokenType.RIGHT_BRACKET, "Expected ']' after options"); this.consume(TokenType.RIGHT_BRACKET, 'Expected \']\' after options');
this.consume(TokenType.SEMICOLON, "Expected ';' after options"); this.consume(TokenType.SEMICOLON, 'Expected \';\' after options');
} }
private match(...types: TokenType[]): boolean { private match(...types: TokenType[]): boolean {
@@ -233,12 +231,14 @@ export class Parser {
} }
private check(type: TokenType): boolean { private check(type: TokenType): boolean {
if (this.isAtEnd()) return false; if (this.isAtEnd())
return false;
return this.peek().type === type; return this.peek().type === type;
} }
private advance(): Token { private advance(): Token {
if (!this.isAtEnd()) this.current++; if (!this.isAtEnd())
this.current++;
return this.previous(); return this.previous();
} }
@@ -247,15 +247,16 @@ export class Parser {
} }
private peek(): Token { 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 { 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 { 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(); const current = this.peek();
throw new Error(`${message}. Got ${current.type} at ${current.line}:${current.column}`); throw new Error(`${message}. Got ${current.type} at ${current.line}:${current.column}`);

View File

@@ -8,5 +8,6 @@ export default defineConfig([
{ {
entry: 'src/cli.ts', entry: 'src/cli.ts',
noExternal: ['@clack/prompts'], noExternal: ['@clack/prompts'],
minify: true,
}, },
]); ]);