Compare commits

..

5 Commits

15 changed files with 1046 additions and 457 deletions

17
examples/locations.ql Normal file
View File

@@ -0,0 +1,17 @@
модуль Локации;
узлы {
лес: {
тип: концовка;
название: "Лес";
описание: "Вы пришли в лес и наслаждаетесь природой";
}
гора: {
тип: концовка;
название: "Гора";
описание: "Вы поднялись на гору и любуетесь видом";
}
}
экспорт [лес, гора];

27
examples/main_modular.ql Normal file
View File

@@ -0,0 +1,27 @@
квест МодульныйПример;
цель "Пример использования модулей в QuestLang";
импорт Локации из "./locations.ql";
граф {
узлы {
старт: {
тип: начальный;
описание: "Вы стоите на развилке";
переходы: [выбор];
}
выбор: {
тип: действие;
описание: "Куда пойти?";
варианты: [
("В лес", @Локации.лес),
("На гору", @Локации.гора)
];
}
}
начало: старт;
}
конец;

View File

@@ -1,5 +1,6 @@
{
"name": "questlang-interpreter",
"name": "questlang",
"type": "module",
"version": "1.0.0",
"description": "TypeScript interpreter for QuestLang programming language",
"license": "MIT",
@@ -11,29 +12,36 @@
],
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"bin": {
"questlang": "dist/cli.js"
},
"files": [
"README.md",
"dist",
"examples"
],
"scripts": {
"build": "tsdown",
"dev": "vitest",
"test": "vitest",
"test": "vitest run",
"coverage": "vitest --coverage",
"lint:check": "eslint ./src",
"lint:fix": "eslint ./src --fix",
"type:check": "tsc --noEmit"
"type:check": "tsc --noEmit",
"prepublish": "pnpm run build && pnpm run type:check && pnpm run test"
},
"dependencies": {
"@clack/prompts": "^0.11.0"
},
"devDependencies": {
"@antfu/eslint-config": "^6.0.0",
"@antfu/eslint-config": "^6.2.0",
"@robonen/tsconfig": "^0.0.2",
"tsdown": "^0.15.9",
"tsdown": "^0.16.4",
"typescript": "^5.9.3",
"vitest": "^4.0.1"
"vitest": "^4.0.9"
}
}

802
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

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,153 @@
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 host = {
readFile: (file: string) => fs.readFileSync(file, 'utf8'),
resolve: (fromFile: string, spec: string) => path.resolve(path.dirname(fromFile), spec),
} as const;
const interpreter = QuestLang.interpret(quest, questPath, host);
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 host = {
readFile: (file: string) => fs.readFileSync(file, 'utf8'),
resolve: (fromFile: string, spec: string) => path.resolve(path.dirname(fromFile), spec),
} as const;
const interpreter = QuestLang.interpret(fs.readFileSync(qPath, 'utf8'), qPath, host);
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 host = {
readFile: (file: string) => fs.readFileSync(file, 'utf8'),
resolve: (fromFile: string, spec: string) => path.resolve(path.dirname(fromFile), spec),
} as const;
const interpreter = QuestLang.interpret(quest, questPath, host);
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

@@ -3,6 +3,7 @@
import type { QuestInterpreter } from '.';
import type { InitialNode, NodeDefinition } from './ast.js';
import fs from 'node:fs';
import path from 'node:path';
import process from 'node:process';
import * as p from '@clack/prompts';
import { QuestLang } from '.';
@@ -122,7 +123,11 @@ class ClackCLI {
spinner.start('Loading quest...');
const source = this.readFile(filename);
const interpreter = QuestLang.interpret(source);
const host = {
readFile: (file: string) => fs.readFileSync(file, 'utf8'),
resolve: (fromFile: string, spec: string) => path.resolve(path.dirname(fromFile), spec),
} as const;
const interpreter = QuestLang.interpret(source, filename, host);
// Validate first
const validation = interpreter.validate();
@@ -237,7 +242,11 @@ class ClackCLI {
spinner.start('Validating quest...');
const source = this.readFile(filename);
const validation = QuestLang.validate(source);
const host = {
readFile: (file: string) => fs.readFileSync(file, 'utf8'),
resolve: (fromFile: string, spec: string) => path.resolve(path.dirname(fromFile), spec),
} as const;
const validation = QuestLang.validate(source, filename, host);
if (validation.isValid) {
spinner.stop('✅ Validation completed');
@@ -268,7 +277,11 @@ class ClackCLI {
spinner.start('Analyzing quest...');
const source = this.readFile(filename);
const interpreter = QuestLang.interpret(source);
const host = {
readFile: (file: string) => fs.readFileSync(file, 'utf8'),
resolve: (fromFile: string, spec: string) => path.resolve(path.dirname(fromFile), spec),
} as const;
const interpreter = QuestLang.interpret(source, filename, host);
const questInfo = interpreter.getQuestInfo();
spinner.stop('✅ Analysis completed');

View File

@@ -1,5 +1,6 @@
import type { QuestProgram } from './ast';
import { QuestInterpreter } from './interpreter';
import type { ModuleHost } from './module-loader';
import { Lexer } from './lexer';
import { Parser } from './parser';
@@ -21,17 +22,17 @@ export class QuestLang {
/**
* Create interpreter from source code
*/
public static interpret(source: string): QuestInterpreter {
public static interpret(source: string, filePath?: string, host?: ModuleHost): QuestInterpreter {
const ast = this.parse(source);
return new QuestInterpreter(ast);
return new QuestInterpreter(ast, filePath, host);
}
/**
* Validate QuestLang source code
*/
public static validate(source: string): { isValid: boolean; errors: string[] } {
public static validate(source: string, filePath?: string, host?: ModuleHost): { isValid: boolean; errors: string[] } {
try {
const interpreter = this.interpret(source);
const interpreter = this.interpret(source, filePath, host);
return interpreter.validate();
}
catch (error) {
@@ -45,5 +46,6 @@ export class QuestLang {
export * from './ast';
export { QuestInterpreter } from './interpreter';
export type { ModuleHost } from './module-loader';
export { Lexer } from './lexer';
export { Parser } from './parser';

View File

@@ -6,6 +6,8 @@ import type {
OptionChoice,
QuestProgram,
} from './ast';
import { ModuleLoader } from './module-loader';
import type { ModuleHost } 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, host?: ModuleHost) {
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 && host && program.imports && program.imports.length > 0) {
this.moduleLoader = new ModuleLoader(host);
// 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;

132
src/module-loader.ts Normal file
View File

@@ -0,0 +1,132 @@
import type { ImportNode, ModuleNode, QuestProgram } from './ast';
import { Lexer } from './lexer';
import { Parser } from './parser';
export interface ModuleHost {
readFile(file: string): string;
resolve(fromFile: string, specifier: string): string;
}
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 host: ModuleHost) {}
public loadQuest(questFile: string): { program: QuestProgram; modules: LoadedModule[] } {
const source = this.host.readFile(questFile);
const program = this.parseQuest(source);
// Parse imports (if any)
const imports: ImportNode[] = program.imports || [];
for (const imp of imports) {
const abs = this.host.resolve(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 = this.host.readFile(file);
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 = this.host.resolve(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;
}
}

View File

@@ -10,11 +10,14 @@ VS Code extension for syntax highlighting of QuestLang - a specialized language
- 🎨 **Color highlighting for strings and numbers**
- 🔧 **Automatic bracket closing**
- 📐 **Automatic indentation**
- 📦 **Modules and imports** (`модуль`, `импорт`, `экспорт`, `из`)
- 🧭 **Cross-module references** `@Модуль.узел`
## Supported Language Elements
### Keywords
- `квест`, `цель`, `граф`, `узлы`, `начало`, `конец`
- `модуль`, `импорт`, `экспорт`, `из`
- `тип`, `описание`, `переходы`, `варианты`, `название`
- `начальный`, `действие`, `концовка`
@@ -28,23 +31,30 @@ VS Code extension for syntax highlighting of QuestLang - a specialized language
## Code Example
```questlang
квест MyQuest;
цель "Quest objective description";
модуль Локации;
узлы {
лес: { тип: концовка; название: "Лес"; описание: "Вы в лесу"; }
}
экспорт [лес];
квест МойКвест;
цель "Описание цели квеста";
импорт Локации из "./locations.ql";
граф {
узлы {
старт: {
тип: начальный;
описание: "Beginning of the adventure";
описание: "Начало приключения";
переходы: [выбор];
}
выбор: {
тип: действие;
описание: "What will you do?";
описание: "Что вы будете делать?";
варианты: [
("Go right", правый_путь),
("Go left", левый_путь)
("Идти в лес", @Локации.лес)
];
}
}

View File

@@ -6,6 +6,12 @@
{
"include": "#comments"
},
{
"include": "#module-declaration"
},
{
"include": "#import-declaration"
},
{
"include": "#quest-declaration"
},
@@ -27,11 +33,42 @@
{
"include": "#identifiers"
},
{
"include": "#module-reference"
},
{
"include": "#punctuation"
}
],
"repository": {
"module-declaration": {
"patterns": [
{
"match": "(модуль)\\s+([а-яёА-ЯЁa-zA-Z_][а-яёА-ЯЁa-zA-Z0-9_]*)",
"captures": {
"1": { "name": "keyword.control.module.questlang" },
"2": { "name": "entity.name.namespace.module.questlang" }
}
}
]
},
"import-declaration": {
"patterns": [
{
"begin": "(импорт)\\s+([а-яёА-ЯЁa-zA-Z_][а-яёА-ЯЁa-zA-Z0-9_]*)\\s+(из)\\s+(\")",
"end": "\"",
"beginCaptures": {
"1": { "name": "keyword.control.import.questlang" },
"2": { "name": "entity.name.namespace.import.questlang" },
"3": { "name": "keyword.control.from.questlang" },
"4": { "name": "punctuation.definition.string.begin.questlang" }
},
"endCaptures": { "0": { "name": "punctuation.definition.string.end.questlang" } },
"contentName": "string.quoted.double.path.questlang",
"patterns": [{ "match": "\\\\.", "name": "constant.character.escape.questlang" }]
}
]
},
"comments": {
"patterns": [
{
@@ -93,7 +130,7 @@
"patterns": [
{
"name": "keyword.control.structure.questlang",
"match": "\\b(граф|узлы|начало|конец)\\b"
"match": "\\b(граф|узлы|начало|конец|модуль|импорт|экспорт|из)\\b"
},
{
"name": "keyword.other.property.questlang",
@@ -105,6 +142,14 @@
}
]
},
"module-reference": {
"patterns": [
{
"name": "support.type.module.reference.questlang",
"match": "@([а-яёА-ЯЁa-zA-Z_][а-яёА-ЯЁa-zA-Z0-9_]*)\\.([а-яёА-ЯЁa-zA-Z_][а-яёА-ЯЁa-zA-Z0-9_]*)"
}
]
},
"strings": {
"patterns": [
{