From 69ea8329e9b97ddd29ecb7e54b45797dca291327 Mon Sep 17 00:00:00 2001 From: robonen Date: Sat, 15 Nov 2025 19:03:36 +0700 Subject: [PATCH] feat: implement module system with imports and exports support --- examples/locations.ql | 17 +++ examples/main_modular.ql | 27 ++++ package.json | 2 +- src/__tests__/lexer.test.ts | 2 +- src/__tests__/modules.test.ts | 141 ++++++++++++++++++ src/ast.ts | 39 +++++ src/cli.ts | 6 +- src/index.ts | 8 +- src/interpreter.ts | 78 ++++++++-- src/lexer.ts | 8 + src/module-loader.ts | 129 ++++++++++++++++ src/parser.ts | 127 +++++++++++++++- vscode-extension/README.md | 22 ++- .../syntaxes/questlang.tmLanguage.json | 47 +++++- 14 files changed, 623 insertions(+), 30 deletions(-) create mode 100644 examples/locations.ql create mode 100644 examples/main_modular.ql create mode 100644 src/__tests__/modules.test.ts create mode 100644 src/module-loader.ts diff --git a/examples/locations.ql b/examples/locations.ql new file mode 100644 index 0000000..ea93d6e --- /dev/null +++ b/examples/locations.ql @@ -0,0 +1,17 @@ +модуль Локации; + +узлы { + лес: { + тип: концовка; + название: "Лес"; + описание: "Вы пришли в лес и наслаждаетесь природой"; + } + + гора: { + тип: концовка; + название: "Гора"; + описание: "Вы поднялись на гору и любуетесь видом"; + } +} + +экспорт [лес, гора]; diff --git a/examples/main_modular.ql b/examples/main_modular.ql new file mode 100644 index 0000000..a007299 --- /dev/null +++ b/examples/main_modular.ql @@ -0,0 +1,27 @@ +квест МодульныйПример; +цель "Пример использования модулей в QuestLang"; + +импорт Локации из "./locations.ql"; + +граф { + узлы { + старт: { + тип: начальный; + описание: "Вы стоите на развилке"; + переходы: [выбор]; + } + + выбор: { + тип: действие; + описание: "Куда пойти?"; + варианты: [ + ("В лес", @Локации.лес), + ("На гору", @Локации.гора) + ]; + } + } + + начало: старт; +} + +конец; diff --git a/package.json b/package.json index 93cc6f1..e97173f 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "scripts": { "build": "tsdown", "dev": "vitest", - "test": "vitest", + "test": "vitest test", "coverage": "vitest --coverage", "lint:check": "eslint ./src", "lint:fix": "eslint ./src --fix", diff --git a/src/__tests__/lexer.test.ts b/src/__tests__/lexer.test.ts index b2f45d9..d05e542 100644 --- a/src/__tests__/lexer.test.ts +++ b/src/__tests__/lexer.test.ts @@ -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'); diff --git a/src/__tests__/modules.test.ts b/src/__tests__/modules.test.ts new file mode 100644 index 0000000..302a1f5 --- /dev/null +++ b/src/__tests__/modules.test.ts @@ -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); + }); +}); diff --git a/src/ast.ts b/src/ast.ts index 32b4ee3..02468f0 100644 --- a/src/ast.ts +++ b/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; + 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; +} diff --git a/src/cli.ts b/src/cli.ts index e1e4296..88fe7b8 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -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'); diff --git a/src/index.ts b/src/index.ts index b61f827..b6ffd5c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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) { diff --git a/src/interpreter.ts b/src/interpreter.ts index 23758de..4f7e3ae 100644 --- a/src/interpreter.ts +++ b/src/interpreter.ts @@ -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): 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; + } } diff --git a/src/lexer.ts b/src/lexer.ts index 1399cd6..4b0e9fe 100644 --- a/src/lexer.ts +++ b/src/lexer.ts @@ -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; diff --git a/src/module-loader.ts b/src/module-loader.ts new file mode 100644 index 0000000..53145f0 --- /dev/null +++ b/src/module-loader.ts @@ -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(); + private visit = new Map(); + + 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 }; + } +} diff --git a/src/parser.ts b/src/parser.ts index 45fde7e..1d3a98b 100644 --- a/src/parser.ts +++ b/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 = {}; + 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: как (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; + } } diff --git a/vscode-extension/README.md b/vscode-extension/README.md index 7693d94..660bf21 100644 --- a/vscode-extension/README.md +++ b/vscode-extension/README.md @@ -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", левый_путь) + ("Идти в лес", @Локации.лес) ]; } } diff --git a/vscode-extension/syntaxes/questlang.tmLanguage.json b/vscode-extension/syntaxes/questlang.tmLanguage.json index fa3f524..146b8b8 100644 --- a/vscode-extension/syntaxes/questlang.tmLanguage.json +++ b/vscode-extension/syntaxes/questlang.tmLanguage.json @@ -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": [ {