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,
rules: {
'unused-imports/no-unused-imports': 'error',
'unused-imports/no-unused-vars': 'error',
}
},
);

View File

@@ -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
View File

@@ -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)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,11 @@
#!/usr/bin/env node
import type { QuestInterpreter } from '.';
import type { InitialNode, NodeDefinition } from './ast.js';
import fs from 'node:fs';
import process from 'node:process';
import * as p from '@clack/prompts';
import { QuestLang, type QuestInterpreter } from '.';
import { type NodeDefinition, type InitialNode } from './ast';
import { QuestLang } from '.';
/**
* Beautiful command-line interface for QuestLang using clack
@@ -32,7 +34,7 @@ class ClackCLI {
p.outro('❌ Usage: questlang-clack play <file.ql>');
process.exit(1);
}
await this.playQuest(args[1]);
await this.playQuest(args[1]!);
break;
case 'validate':
@@ -40,7 +42,7 @@ class ClackCLI {
p.outro('❌ Usage: questlang-clack validate <file.ql>');
process.exit(1);
}
await this.validateQuest(args[1]);
await this.validateQuest(args[1]!);
break;
case 'analyze':
@@ -48,7 +50,7 @@ class ClackCLI {
p.outro('❌ Usage: questlang-clack analyze <file.ql>');
process.exit(1);
}
await this.analyzeQuest(args[1]);
await this.analyzeQuest(args[1]!);
break;
default:
@@ -107,7 +109,8 @@ class ClackCLI {
return fs.readdirSync('.')
.filter(file => file.endsWith('.ql'))
.sort();
} catch {
}
catch {
return [];
}
}
@@ -138,8 +141,8 @@ class ClackCLI {
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');
@@ -166,8 +169,8 @@ class ClackCLI {
message: 'What do you want to do?',
options: choices.map((choice, index) => ({
value: index,
label: choice.text
}))
label: choice.text,
})),
});
if (p.isCancel(choice)) {
@@ -181,16 +184,23 @@ class ClackCLI {
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,7 +213,7 @@ class ClackCLI {
const finalNode = interpreter.getCurrentNode();
p.note(
finalNode?.description || 'Quest completed',
`🏆 ${state.endingTitle}`
`🏆 ${state.endingTitle}`,
);
const playAgain = await p.confirm({
@@ -213,7 +223,8 @@ class ClackCLI {
if (!p.isCancel(playAgain) && playAgain) {
interpreter.reset();
await this.gameLoop(interpreter);
} else {
}
else {
p.outro('Thanks for playing! 🎉');
}
}
@@ -232,7 +243,8 @@ class ClackCLI {
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:');
@@ -240,7 +252,8 @@ class ClackCLI {
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');
@@ -266,12 +279,12 @@ class ClackCLI {
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
@@ -295,13 +308,14 @@ class ClackCLI {
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');
@@ -320,7 +334,7 @@ class ClackCLI {
const cli = new ClackCLI();
cli.run().catch(error => {
cli.run().catch((error) => {
p.log.error(`Unexpected error: ${error}`);
p.outro('❌ CLI crashed');
process.exit(1);

View File

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

View File

@@ -1,11 +1,10 @@
import type {
QuestProgram,
GraphNode,
NodeDefinition,
InitialNode,
ActionNode,
EndingNode,
OptionChoice
InitialNode,
NodeDefinition,
OptionChoice,
QuestProgram,
} from './ast';
/**
@@ -31,9 +30,9 @@ export interface ExecutionResult {
* Visitor interface for quest nodes
*/
export interface QuestVisitor {
visitInitialNode(node: InitialNode, state: QuestState): void;
visitActionNode(node: ActionNode, state: QuestState): void;
visitEndingNode(node: EndingNode, state: QuestState): void;
visitInitialNode: (node: InitialNode, state: QuestState) => void;
visitActionNode: (node: ActionNode, state: QuestState) => void;
visitEndingNode: (node: EndingNode, state: QuestState) => void;
}
/**
@@ -48,7 +47,7 @@ export class QuestInterpreter {
this.currentState = {
currentNode: program.graph.start,
history: [],
isComplete: false
isComplete: false,
};
}
@@ -66,7 +65,7 @@ export class QuestInterpreter {
return {
name: this.program.name,
goal: this.program.goal,
isComplete: this.currentState.isComplete
isComplete: this.currentState.isComplete,
};
}
@@ -106,7 +105,7 @@ export class QuestInterpreter {
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);
}
@@ -143,7 +149,7 @@ export class QuestInterpreter {
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,
};
}
@@ -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]) {
@@ -256,15 +264,17 @@ export class QuestInterpreter {
return {
isValid: errors.length === 0,
errors
errors,
};
}
private findReachableNodes(nodeId: string, reachable: Set<string>): void {
if (reachable.has(nodeId)) return;
if (reachable.has(nodeId))
return;
const node = this.program.graph.nodes[nodeId];
if (!node) return;
if (!node)
return;
reachable.add(nodeId);
@@ -273,7 +283,8 @@ export class QuestInterpreter {
for (const option of actionNode.options) {
this.findReachableNodes(option.target, reachable);
}
} else if (node.nodeType === 'начальный') {
}
else if (node.nodeType === 'начальный') {
const initialNode = node as InitialNode;
for (const transition of initialNode.transitions) {
this.findReachableNodes(transition, reachable);

View File

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

View File

@@ -1,17 +1,5 @@
import type { ActionNode, EndingNode, GraphNode, InitialNode, NodeDefinition, OptionChoice, QuestProgram, Token } from './ast';
import { TokenType } from './ast';
import type { Token } from './ast';
import type {
ASTNode,
QuestProgram,
GraphNode,
NodeDefinition,
InitialNode,
ActionNode,
EndingNode,
OptionChoice,
StringLiteral,
Identifier
} from './ast';
/**
* Parser for QuestLang
@@ -23,8 +11,8 @@ export class Parser {
constructor(tokens: Token[]) {
// Filter out comments and whitespace
this.tokens = tokens.filter(token =>
token.type !== TokenType.COMMENT &&
token.type !== TokenType.WHITESPACE
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,15 +247,16 @@ 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}`);

View File

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