feat: implement module system with imports and exports support

This commit is contained in:
2025-11-15 19:03:36 +07:00
parent 3a0f152c6e
commit 69ea8329e9
14 changed files with 623 additions and 30 deletions

View File

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

View 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);
});
});

View File

@@ -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;
}

View File

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

View File

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

View File

@@ -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;
}
}

View File

@@ -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
View 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 };
}
}

View File

@@ -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;
}
}