mirror of
https://github.com/robonen/questlang.git
synced 2026-03-20 10:54:45 +00:00
feat: implement module system with imports and exports support
This commit is contained in:
@@ -91,7 +91,7 @@ describe('lexer', () => {
|
||||
});
|
||||
|
||||
it('should throw error on unexpected character', () => {
|
||||
const source = 'квест @';
|
||||
const source = 'квест $';
|
||||
const lexer = new Lexer(source);
|
||||
|
||||
expect(() => lexer.tokenize()).toThrow('Unexpected character');
|
||||
|
||||
141
src/__tests__/modules.test.ts
Normal file
141
src/__tests__/modules.test.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { QuestLang } from '..';
|
||||
import { Lexer } from '../lexer';
|
||||
import { Parser } from '../parser';
|
||||
|
||||
describe('module system', () => {
|
||||
it('parses a module with exports', () => {
|
||||
const src = `
|
||||
модуль Тест;
|
||||
узлы {
|
||||
финал: { тип: концовка; название: "x"; описание: "y"; }
|
||||
}
|
||||
экспорт [финал];
|
||||
`;
|
||||
const lexer = new Lexer(src);
|
||||
const tokens = lexer.tokenize();
|
||||
const parser = new Parser(tokens);
|
||||
const ast = parser.parseAny() as any;
|
||||
|
||||
expect(ast.type).toBe('Module');
|
||||
expect(ast.name).toBe('Тест');
|
||||
expect(ast.nodes.финал).toBeDefined();
|
||||
expect(ast.exports).toEqual(['финал']);
|
||||
});
|
||||
|
||||
it('imports a module and validates module-qualified references', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-mod-'));
|
||||
|
||||
const modulePath = path.join(dir, 'loc.ql');
|
||||
fs.writeFileSync(modulePath, `
|
||||
модуль Локации;
|
||||
узлы {
|
||||
лес: { тип: концовка; название: "Лес"; описание: "Вы в лесу"; }
|
||||
}
|
||||
экспорт [лес];
|
||||
`);
|
||||
|
||||
const questPath = path.join(dir, 'main.ql');
|
||||
const quest = `
|
||||
квест Модульный;
|
||||
цель "Тест модулей";
|
||||
импорт Локации из "./loc.ql";
|
||||
граф {
|
||||
узлы {
|
||||
старт: { тип: начальный; описание: "начало"; переходы: [шаг]; }
|
||||
шаг: { тип: действие; описание: "куда?"; варианты: [("В лес", @Локации.лес)]; }
|
||||
}
|
||||
начало: старт;
|
||||
}
|
||||
конец;
|
||||
`;
|
||||
fs.writeFileSync(questPath, quest);
|
||||
|
||||
const interpreter = QuestLang.interpret(quest, questPath);
|
||||
const validation = interpreter.validate();
|
||||
expect(validation.isValid).toBe(true);
|
||||
});
|
||||
|
||||
it('supports cyclic imports between modules', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-mod-'));
|
||||
|
||||
// Module A imports B and points to @B.b
|
||||
const aPath = path.join(dir, 'a.ql');
|
||||
fs.writeFileSync(aPath, `
|
||||
модуль A;
|
||||
импорт B из "./b.ql";
|
||||
узлы {
|
||||
a: { тип: действие; описание: "a"; варианты: [("go", @B.b)]; }
|
||||
}
|
||||
экспорт [a];
|
||||
`);
|
||||
|
||||
// Module B imports A and points to @A.a
|
||||
const bPath = path.join(dir, 'b.ql');
|
||||
fs.writeFileSync(bPath, `
|
||||
модуль B;
|
||||
импорт A из "./a.ql";
|
||||
узлы {
|
||||
b: { тип: действие; описание: "b"; варианты: [("go", @A.a)]; }
|
||||
}
|
||||
экспорт [b];
|
||||
`);
|
||||
|
||||
// Main quest imports A and can reach @A.a
|
||||
const qPath = path.join(dir, 'main.ql');
|
||||
fs.writeFileSync(qPath, `
|
||||
квест Q;
|
||||
цель "cyclic";
|
||||
импорт A из "./a.ql";
|
||||
граф {
|
||||
узлы {
|
||||
старт: { тип: начальный; описание: "s"; переходы: [go]; }
|
||||
go: { тип: действие; описание: "go"; варианты: [("to A", @A.a)]; }
|
||||
}
|
||||
начало: старт;
|
||||
}
|
||||
конец;
|
||||
`);
|
||||
|
||||
const interpreter = QuestLang.interpret(fs.readFileSync(qPath, 'utf8'), qPath);
|
||||
const validation = interpreter.validate();
|
||||
expect(validation.isValid).toBe(true);
|
||||
});
|
||||
|
||||
it('fails validation when referencing non-exported node', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-mod-'));
|
||||
|
||||
const modulePath = path.join(dir, 'loc.ql');
|
||||
fs.writeFileSync(modulePath, `
|
||||
модуль Локации;
|
||||
узлы {
|
||||
секрет: { тип: концовка; название: "секрет"; описание: "секрет"; }
|
||||
}
|
||||
экспорт [];
|
||||
`);
|
||||
|
||||
const questPath = path.join(dir, 'main.ql');
|
||||
const quest = `
|
||||
квест Модульный;
|
||||
цель "Тест модулей";
|
||||
импорт Локации из "./loc.ql";
|
||||
граф {
|
||||
узлы {
|
||||
старт: { тип: начальный; описание: "начало"; переходы: [шаг]; }
|
||||
шаг: { тип: действие; описание: "куда?"; варианты: [("Секрет", @Локации.секрет)]; }
|
||||
}
|
||||
начало: старт;
|
||||
}
|
||||
конец;
|
||||
`;
|
||||
fs.writeFileSync(questPath, quest);
|
||||
|
||||
const interpreter = QuestLang.interpret(quest, questPath);
|
||||
const validation = interpreter.validate();
|
||||
expect(validation.isValid).toBe(false);
|
||||
expect(validation.errors.some(e => e.includes('non-existent') || e.includes('not exported'))).toBe(true);
|
||||
});
|
||||
});
|
||||
39
src/ast.ts
39
src/ast.ts
@@ -20,6 +20,12 @@ export enum TokenType {
|
||||
OPTIONS = 'варианты',
|
||||
TITLE = 'название',
|
||||
|
||||
// Module system keywords
|
||||
MODULE = 'модуль',
|
||||
IMPORT = 'импорт',
|
||||
EXPORT = 'экспорт',
|
||||
FROM = 'из',
|
||||
|
||||
// Node types
|
||||
INITIAL = 'начальный',
|
||||
ACTION = 'действие',
|
||||
@@ -30,6 +36,7 @@ export enum TokenType {
|
||||
COLON = ':',
|
||||
COMMA = ',',
|
||||
DOT = '.',
|
||||
AT = '@',
|
||||
LEFT_BRACE = '{',
|
||||
RIGHT_BRACE = '}',
|
||||
LEFT_BRACKET = '[',
|
||||
@@ -82,6 +89,8 @@ export interface QuestProgram extends ASTNode {
|
||||
name: string;
|
||||
goal: string;
|
||||
graph: GraphNode;
|
||||
// Imports are only used by module-enabled programs; optional for backward compatibility
|
||||
imports?: ImportNode[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -132,6 +141,7 @@ export interface EndingNode extends NodeDefinition {
|
||||
export interface OptionChoice extends ASTNode {
|
||||
type: 'OptionChoice';
|
||||
text: string;
|
||||
// Target can be a local node id ("узел") or a module-qualified reference ("@Модуль.узел") as a raw string
|
||||
target: string;
|
||||
}
|
||||
|
||||
@@ -150,3 +160,32 @@ export interface Identifier extends ASTNode {
|
||||
type: 'Identifier';
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Module declaration AST
|
||||
*/
|
||||
export interface ModuleNode extends ASTNode {
|
||||
type: 'Module';
|
||||
name: string;
|
||||
nodes: Record<string, NodeDefinition>;
|
||||
exports: string[];
|
||||
imports?: ImportNode[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Import declaration AST
|
||||
*/
|
||||
export interface ImportNode extends ASTNode {
|
||||
type: 'Import';
|
||||
moduleName: string;
|
||||
modulePath: string; // path in quotes as provided in source
|
||||
alias?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Module reference helper type (not necessarily emitted in AST; we encode as string "@Module.node")
|
||||
*/
|
||||
export interface ModuleReference {
|
||||
module: string;
|
||||
nodeId: string;
|
||||
}
|
||||
|
||||
@@ -122,7 +122,7 @@ class ClackCLI {
|
||||
spinner.start('Loading quest...');
|
||||
|
||||
const source = this.readFile(filename);
|
||||
const interpreter = QuestLang.interpret(source);
|
||||
const interpreter = QuestLang.interpret(source, filename);
|
||||
|
||||
// Validate first
|
||||
const validation = interpreter.validate();
|
||||
@@ -237,7 +237,7 @@ class ClackCLI {
|
||||
spinner.start('Validating quest...');
|
||||
|
||||
const source = this.readFile(filename);
|
||||
const validation = QuestLang.validate(source);
|
||||
const validation = QuestLang.validate(source, filename);
|
||||
|
||||
if (validation.isValid) {
|
||||
spinner.stop('✅ Validation completed');
|
||||
@@ -268,7 +268,7 @@ class ClackCLI {
|
||||
spinner.start('Analyzing quest...');
|
||||
|
||||
const source = this.readFile(filename);
|
||||
const interpreter = QuestLang.interpret(source);
|
||||
const interpreter = QuestLang.interpret(source, filename);
|
||||
const questInfo = interpreter.getQuestInfo();
|
||||
|
||||
spinner.stop('✅ Analysis completed');
|
||||
|
||||
@@ -21,17 +21,17 @@ export class QuestLang {
|
||||
/**
|
||||
* Create interpreter from source code
|
||||
*/
|
||||
public static interpret(source: string): QuestInterpreter {
|
||||
public static interpret(source: string, filePath?: string): QuestInterpreter {
|
||||
const ast = this.parse(source);
|
||||
return new QuestInterpreter(ast);
|
||||
return new QuestInterpreter(ast, filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate QuestLang source code
|
||||
*/
|
||||
public static validate(source: string): { isValid: boolean; errors: string[] } {
|
||||
public static validate(source: string, filePath?: string): { isValid: boolean; errors: string[] } {
|
||||
try {
|
||||
const interpreter = this.interpret(source);
|
||||
const interpreter = this.interpret(source, filePath);
|
||||
return interpreter.validate();
|
||||
}
|
||||
catch (error) {
|
||||
|
||||
@@ -6,6 +6,8 @@ import type {
|
||||
OptionChoice,
|
||||
QuestProgram,
|
||||
} from './ast';
|
||||
import path from 'node:path';
|
||||
import { ModuleLoader } from './module-loader';
|
||||
|
||||
/**
|
||||
* Runtime state of the quest
|
||||
@@ -41,14 +43,22 @@ export interface QuestVisitor {
|
||||
export class QuestInterpreter {
|
||||
private program: QuestProgram;
|
||||
private currentState: QuestState;
|
||||
private moduleLoader?: ModuleLoader;
|
||||
|
||||
constructor(program: QuestProgram) {
|
||||
constructor(program: QuestProgram, questFilePath?: string) {
|
||||
this.program = program;
|
||||
this.currentState = {
|
||||
currentNode: program.graph.start,
|
||||
history: [],
|
||||
isComplete: false,
|
||||
};
|
||||
|
||||
// Initialize module loader if imports present and quest file path provided
|
||||
if (questFilePath && program.imports && program.imports.length > 0) {
|
||||
this.moduleLoader = new ModuleLoader(path.dirname(questFilePath));
|
||||
// Load modules
|
||||
this.moduleLoader.loadQuest(questFilePath);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -81,7 +91,7 @@ export class QuestInterpreter {
|
||||
*/
|
||||
public getCurrentNode(): NodeDefinition | null {
|
||||
const nodeId = this.currentState.currentNode;
|
||||
return this.program.graph.nodes[nodeId] || null;
|
||||
return this.resolveNode(nodeId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -143,7 +153,7 @@ export class QuestInterpreter {
|
||||
* Move to a specific node
|
||||
*/
|
||||
public moveToNode(nodeId: string): ExecutionResult {
|
||||
const targetNode = this.program.graph.nodes[nodeId];
|
||||
const targetNode = this.resolveNode(nodeId);
|
||||
|
||||
if (!targetNode) {
|
||||
return {
|
||||
@@ -193,7 +203,7 @@ export class QuestInterpreter {
|
||||
}
|
||||
|
||||
private findPaths(nodeId: string, currentPath: string[], allPaths: string[][], visited: Set<string>): void {
|
||||
const node = this.program.graph.nodes[nodeId];
|
||||
const node = this.resolveNode(nodeId);
|
||||
|
||||
if (!node || visited.has(nodeId)) {
|
||||
return;
|
||||
@@ -228,7 +238,7 @@ export class QuestInterpreter {
|
||||
const nodeIds = Object.keys(this.program.graph.nodes);
|
||||
|
||||
// Check if start node exists
|
||||
if (!this.program.graph.nodes[this.program.graph.start]) {
|
||||
if (!this.resolveNode(this.program.graph.start)) {
|
||||
errors.push(`Start node '${this.program.graph.start}' does not exist`);
|
||||
}
|
||||
|
||||
@@ -237,16 +247,36 @@ export class QuestInterpreter {
|
||||
if (node.nodeType === 'действие') {
|
||||
const actionNode = node as ActionNode;
|
||||
for (const option of actionNode.options) {
|
||||
if (!this.program.graph.nodes[option.target]) {
|
||||
errors.push(`Node '${nodeId}' references non-existent target '${option.target}'`);
|
||||
if (!this.resolveNode(option.target)) {
|
||||
if (option.target.startsWith('@') && this.moduleLoader) {
|
||||
const ref = option.target.slice(1);
|
||||
const dot = ref.indexOf('.');
|
||||
const modName = dot >= 0 ? ref.slice(0, dot) : ref;
|
||||
const nid = dot >= 0 ? ref.slice(dot + 1) : '';
|
||||
const check = dot >= 0 ? this.moduleLoader.resolveExport(modName, nid) : { ok: false, error: `Invalid module reference '${option.target}'` } as const;
|
||||
errors.push(check.ok ? `Node '${nodeId}' references non-existent target '${option.target}'` : check.error);
|
||||
}
|
||||
else {
|
||||
errors.push(`Node '${nodeId}' references non-existent target '${option.target}'`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (node.nodeType === 'начальный') {
|
||||
const initialNode = node as InitialNode;
|
||||
for (const transition of initialNode.transitions) {
|
||||
if (!this.program.graph.nodes[transition]) {
|
||||
errors.push(`Initial node '${nodeId}' references non-existent transition '${transition}'`);
|
||||
if (!this.resolveNode(transition)) {
|
||||
if (transition.startsWith('@') && this.moduleLoader) {
|
||||
const ref = transition.slice(1);
|
||||
const dot = ref.indexOf('.');
|
||||
const modName = dot >= 0 ? ref.slice(0, dot) : ref;
|
||||
const nid = dot >= 0 ? ref.slice(dot + 1) : '';
|
||||
const check = dot >= 0 ? this.moduleLoader.resolveExport(modName, nid) : { ok: false, error: `Invalid module reference '${transition}'` } as const;
|
||||
errors.push(check.ok ? `Initial node '${nodeId}' references non-existent transition '${transition}'` : check.error);
|
||||
}
|
||||
else {
|
||||
errors.push(`Initial node '${nodeId}' references non-existent transition '${transition}'`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -272,7 +302,7 @@ export class QuestInterpreter {
|
||||
if (reachable.has(nodeId))
|
||||
return;
|
||||
|
||||
const node = this.program.graph.nodes[nodeId];
|
||||
const node = this.resolveNode(nodeId);
|
||||
if (!node)
|
||||
return;
|
||||
|
||||
@@ -291,4 +321,32 @@ export class QuestInterpreter {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve either local node id or module-qualified reference '@Module.node'
|
||||
*/
|
||||
private resolveNode(id: string): NodeDefinition | null {
|
||||
// Module-qualified
|
||||
if (id.startsWith('@')) {
|
||||
const ref = id.slice(1);
|
||||
const dot = ref.indexOf('.');
|
||||
if (dot === -1)
|
||||
return null;
|
||||
const moduleName = ref.slice(0, dot);
|
||||
const nodeId = ref.slice(dot + 1);
|
||||
|
||||
if (!this.moduleLoader)
|
||||
return null;
|
||||
const check = this.moduleLoader.resolveExport(moduleName, nodeId);
|
||||
if (!check.ok)
|
||||
return null;
|
||||
const mod = this.moduleLoader.getModuleByName(moduleName);
|
||||
if (!mod)
|
||||
return null;
|
||||
return mod.ast.nodes[nodeId] || null;
|
||||
}
|
||||
|
||||
// Local
|
||||
return this.program.graph.nodes[id] || null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,11 @@ export class Lexer {
|
||||
['переходы', TokenType.TRANSITIONS],
|
||||
['варианты', TokenType.OPTIONS],
|
||||
['название', TokenType.TITLE],
|
||||
// Module system
|
||||
['модуль', TokenType.MODULE],
|
||||
['импорт', TokenType.IMPORT],
|
||||
['экспорт', TokenType.EXPORT],
|
||||
['из', TokenType.FROM],
|
||||
['начальный', TokenType.INITIAL],
|
||||
['действие', TokenType.ACTION],
|
||||
['концовка', TokenType.ENDING],
|
||||
@@ -79,6 +84,9 @@ export class Lexer {
|
||||
case '.':
|
||||
this.addToken(TokenType.DOT, c, start, startLine, startColumn);
|
||||
break;
|
||||
case '@':
|
||||
this.addToken(TokenType.AT, c, start, startLine, startColumn);
|
||||
break;
|
||||
case '{':
|
||||
this.addToken(TokenType.LEFT_BRACE, c, start, startLine, startColumn);
|
||||
break;
|
||||
|
||||
129
src/module-loader.ts
Normal file
129
src/module-loader.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import type { ImportNode, ModuleNode, QuestProgram } from './ast';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { Lexer } from './lexer';
|
||||
import { Parser } from './parser';
|
||||
|
||||
export enum VisitState {
|
||||
Unvisited,
|
||||
Visiting,
|
||||
Visited,
|
||||
}
|
||||
|
||||
export interface LoadedModule {
|
||||
name: string;
|
||||
file: string;
|
||||
ast: ModuleNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cycle-tolerant module loader: parse first, link/validate later.
|
||||
*/
|
||||
export class ModuleLoader {
|
||||
private byFile = new Map<string, LoadedModule>();
|
||||
private visit = new Map<string, VisitState>();
|
||||
|
||||
constructor(private baseDir: string) {}
|
||||
|
||||
public loadQuest(questFile: string): { program: QuestProgram; modules: LoadedModule[] } {
|
||||
const source = fs.readFileSync(questFile, 'utf8');
|
||||
const program = this.parseQuest(source);
|
||||
|
||||
// Parse imports (if any)
|
||||
const imports: ImportNode[] = program.imports || [];
|
||||
|
||||
for (const imp of imports) {
|
||||
const abs = path.resolve(path.dirname(questFile), imp.modulePath);
|
||||
this.dfsParse(abs);
|
||||
}
|
||||
|
||||
return { program, modules: [...this.byFile.values()] };
|
||||
}
|
||||
|
||||
private dfsParse(file: string): void {
|
||||
const state = this.visit.get(file) ?? VisitState.Unvisited;
|
||||
if (state === VisitState.Visited)
|
||||
return;
|
||||
if (state === VisitState.Visiting) {
|
||||
// cycle detected — allowed, just return
|
||||
return;
|
||||
}
|
||||
|
||||
this.visit.set(file, VisitState.Visiting);
|
||||
|
||||
if (!this.byFile.has(file)) {
|
||||
const src = fs.readFileSync(file, 'utf8');
|
||||
const ast = this.parseModule(src, file);
|
||||
this.byFile.set(file, { name: ast.name, file, ast });
|
||||
|
||||
// Follow module's own imports (if any)
|
||||
const ownImports = ast.imports || [];
|
||||
for (const imp of ownImports) {
|
||||
const dep = path.resolve(path.dirname(file), imp.modulePath);
|
||||
this.dfsParse(dep);
|
||||
}
|
||||
}
|
||||
|
||||
this.visit.set(file, VisitState.Visited);
|
||||
}
|
||||
|
||||
private parseQuest(src: string): QuestProgram {
|
||||
const lexer = new Lexer(src);
|
||||
const tokens = lexer.tokenize();
|
||||
const parser = new Parser(tokens);
|
||||
const ast = parser.parse();
|
||||
return ast;
|
||||
}
|
||||
|
||||
private parseModule(src: string, file: string): ModuleNode {
|
||||
const lexer = new Lexer(src);
|
||||
const tokens = lexer.tokenize();
|
||||
const parser = new Parser(tokens);
|
||||
const any = parser.parseAny();
|
||||
if ((any as any).type !== 'Module') {
|
||||
throw new Error(`Expected module in ${file}`);
|
||||
}
|
||||
return any as ModuleNode;
|
||||
}
|
||||
|
||||
/** Resolve an exported node existence */
|
||||
public resolveExport(moduleName: string, nodeId: string): { ok: true } | { ok: false; error: string } {
|
||||
for (const mod of this.byFile.values()) {
|
||||
if (mod.ast.name === moduleName) {
|
||||
if (!mod.ast.nodes[nodeId]) {
|
||||
return { ok: false, error: `Module '${moduleName}' has no node '${nodeId}'` };
|
||||
}
|
||||
if (!mod.ast.exports.includes(nodeId)) {
|
||||
return { ok: false, error: `Node '${nodeId}' is not exported by module '${moduleName}'` };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
return { ok: false, error: `Module '${moduleName}' not found` };
|
||||
}
|
||||
|
||||
public getModuleByName(name: string): LoadedModule | undefined {
|
||||
for (const mod of this.byFile.values()) {
|
||||
if (mod.ast.name === name)
|
||||
return mod;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
public getAllModules(): LoadedModule[] {
|
||||
return [...this.byFile.values()];
|
||||
}
|
||||
|
||||
/** Validate module-level invariants (exports exist, etc.) */
|
||||
public validateModules(): { isValid: boolean; errors: string[] } {
|
||||
const errors: string[] = [];
|
||||
for (const mod of this.byFile.values()) {
|
||||
for (const exp of mod.ast.exports) {
|
||||
if (!mod.ast.nodes[exp]) {
|
||||
errors.push(`Module ${mod.ast.name}: exported node '${exp}' does not exist`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { isValid: errors.length === 0, errors };
|
||||
}
|
||||
}
|
||||
127
src/parser.ts
127
src/parser.ts
@@ -1,4 +1,4 @@
|
||||
import type { ActionNode, EndingNode, GraphNode, InitialNode, NodeDefinition, OptionChoice, QuestProgram, Token } from './ast';
|
||||
import type { ActionNode, EndingNode, GraphNode, ImportNode, InitialNode, ModuleNode, NodeDefinition, OptionChoice, QuestProgram, Token } from './ast';
|
||||
import { TokenType } from './ast';
|
||||
|
||||
/**
|
||||
@@ -17,18 +17,29 @@ export class Parser {
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the entire program
|
||||
* Parse the entire program strictly as a quest (backward compatibility for existing API)
|
||||
*/
|
||||
public parse(): QuestProgram {
|
||||
return this.parseQuest();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse either a module or a quest program.
|
||||
*/
|
||||
public parseAny(): QuestProgram | ModuleNode {
|
||||
if (this.check(TokenType.MODULE)) {
|
||||
return this.parseModule();
|
||||
}
|
||||
return this.parseQuest();
|
||||
}
|
||||
|
||||
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 goal = this.parseGoal();
|
||||
const imports = this.parseImports();
|
||||
const graph = this.parseGraph();
|
||||
|
||||
this.consume(TokenType.END, 'Expected \'конец\'');
|
||||
@@ -39,11 +50,56 @@ export class Parser {
|
||||
name,
|
||||
goal,
|
||||
graph,
|
||||
// Only attach if there were imports to preserve older shape
|
||||
...(imports.length > 0 ? { imports } : {}),
|
||||
line: questToken.line,
|
||||
column: questToken.column,
|
||||
};
|
||||
}
|
||||
|
||||
private parseModule(): ModuleNode {
|
||||
const moduleToken = this.consume(TokenType.MODULE, 'Expected \'модуль\'');
|
||||
const name = this.consume(TokenType.IDENTIFIER, 'Expected module name').value;
|
||||
this.consume(TokenType.SEMICOLON, 'Expected \';\' after module name');
|
||||
|
||||
const nodes: Record<string, NodeDefinition> = {};
|
||||
let exports: string[] = [];
|
||||
const imports: ImportNode[] = [];
|
||||
|
||||
// Module body: allow 'узлы { ... }' and optional 'экспорт [..];' in any order
|
||||
while (!this.isAtEnd()) {
|
||||
if (this.match(TokenType.IMPORT)) {
|
||||
// Rewind one token back because parseImports expects current to be on IMPORT
|
||||
this.current--;
|
||||
const more = this.parseImports();
|
||||
imports.push(...more);
|
||||
}
|
||||
else if (this.match(TokenType.NODES)) {
|
||||
this.parseNodes(nodes);
|
||||
}
|
||||
else if (this.match(TokenType.EXPORT)) {
|
||||
exports = this.parseExports();
|
||||
}
|
||||
else if (this.check(TokenType.EOF)) {
|
||||
break;
|
||||
}
|
||||
else {
|
||||
// Unknown token — be explicit
|
||||
throw new Error(`Unexpected token in module: ${this.peek().type} at ${this.peek().line}:${this.peek().column}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'Module',
|
||||
name,
|
||||
nodes,
|
||||
exports,
|
||||
...(imports.length > 0 ? { imports } : {}),
|
||||
line: moduleToken.line,
|
||||
column: moduleToken.column,
|
||||
} as ModuleNode;
|
||||
}
|
||||
|
||||
private parseGoal(): string {
|
||||
this.consume(TokenType.GOAL, 'Expected \'цель\'');
|
||||
const goalValue = this.consume(TokenType.STRING, 'Expected goal description').value;
|
||||
@@ -185,7 +241,7 @@ export class Parser {
|
||||
|
||||
if (!this.check(TokenType.RIGHT_BRACKET)) {
|
||||
do {
|
||||
const transition = this.consume(TokenType.IDENTIFIER, 'Expected transition identifier').value;
|
||||
const transition = this.parseTargetString();
|
||||
transitions.push(transition);
|
||||
} while (this.match(TokenType.COMMA));
|
||||
}
|
||||
@@ -203,7 +259,7 @@ export class Parser {
|
||||
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;
|
||||
const target = this.parseTargetString();
|
||||
this.consume(TokenType.RIGHT_PAREN, 'Expected \')\' after option');
|
||||
|
||||
options.push({
|
||||
@@ -261,4 +317,67 @@ export class Parser {
|
||||
const current = this.peek();
|
||||
throw new Error(`${message}. Got ${current.type} at ${current.line}:${current.column}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse zero or more import declarations appearing before the graph.
|
||||
*/
|
||||
private parseImports(): ImportNode[] {
|
||||
const imports: ImportNode[] = [];
|
||||
|
||||
while (this.match(TokenType.IMPORT)) {
|
||||
const importTok = this.previous();
|
||||
const moduleName = this.consume(TokenType.IDENTIFIER, 'Expected module name').value;
|
||||
|
||||
// Optional alias syntax: <name> как <alias> (not implemented now)
|
||||
|
||||
this.consume(TokenType.FROM, 'Expected \'из\'');
|
||||
const modulePathTok = this.consume(TokenType.STRING, 'Expected module path');
|
||||
this.consume(TokenType.SEMICOLON, 'Expected \';\' after import');
|
||||
|
||||
imports.push({
|
||||
type: 'Import',
|
||||
moduleName,
|
||||
modulePath: modulePathTok.value,
|
||||
line: importTok.line,
|
||||
column: importTok.column,
|
||||
});
|
||||
}
|
||||
|
||||
return imports;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse export list: экспорт [a, b];
|
||||
*/
|
||||
private parseExports(): string[] {
|
||||
this.consume(TokenType.LEFT_BRACKET, 'Expected \'[\' after \'экспорт\'');
|
||||
const exports: string[] = [];
|
||||
|
||||
if (!this.check(TokenType.RIGHT_BRACKET)) {
|
||||
do {
|
||||
const id = this.consume(TokenType.IDENTIFIER, 'Expected exported node identifier').value;
|
||||
exports.push(id);
|
||||
} while (this.match(TokenType.COMMA));
|
||||
}
|
||||
|
||||
this.consume(TokenType.RIGHT_BRACKET, '] expected after export list');
|
||||
this.consume(TokenType.SEMICOLON, 'Expected \';\' after export list');
|
||||
|
||||
return exports;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a local or module-qualified target into a raw string.
|
||||
* Examples: идентификатор | @Модуль.ид
|
||||
*/
|
||||
private parseTargetString(): string {
|
||||
if (this.match(TokenType.AT)) {
|
||||
const moduleName = this.consume(TokenType.IDENTIFIER, 'Expected module name after @').value;
|
||||
this.consume(TokenType.DOT, 'Expected \'.\' after module name');
|
||||
const nodeId = this.consume(TokenType.IDENTIFIER, 'Expected node identifier after module name').value;
|
||||
return `@${moduleName}.${nodeId}`;
|
||||
}
|
||||
// Local identifier
|
||||
return this.consume(TokenType.IDENTIFIER, 'Expected target identifier').value;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user