mirror of
https://github.com/robonen/questlang.git
synced 2026-03-20 02:44:47 +00:00
feat: implement module system with imports and exports support
This commit is contained in:
17
examples/locations.ql
Normal file
17
examples/locations.ql
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
модуль Локации;
|
||||||
|
|
||||||
|
узлы {
|
||||||
|
лес: {
|
||||||
|
тип: концовка;
|
||||||
|
название: "Лес";
|
||||||
|
описание: "Вы пришли в лес и наслаждаетесь природой";
|
||||||
|
}
|
||||||
|
|
||||||
|
гора: {
|
||||||
|
тип: концовка;
|
||||||
|
название: "Гора";
|
||||||
|
описание: "Вы поднялись на гору и любуетесь видом";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
экспорт [лес, гора];
|
||||||
27
examples/main_modular.ql
Normal file
27
examples/main_modular.ql
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
квест МодульныйПример;
|
||||||
|
цель "Пример использования модулей в QuestLang";
|
||||||
|
|
||||||
|
импорт Локации из "./locations.ql";
|
||||||
|
|
||||||
|
граф {
|
||||||
|
узлы {
|
||||||
|
старт: {
|
||||||
|
тип: начальный;
|
||||||
|
описание: "Вы стоите на развилке";
|
||||||
|
переходы: [выбор];
|
||||||
|
}
|
||||||
|
|
||||||
|
выбор: {
|
||||||
|
тип: действие;
|
||||||
|
описание: "Куда пойти?";
|
||||||
|
варианты: [
|
||||||
|
("В лес", @Локации.лес),
|
||||||
|
("На гору", @Локации.гора)
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
начало: старт;
|
||||||
|
}
|
||||||
|
|
||||||
|
конец;
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsdown",
|
"build": "tsdown",
|
||||||
"dev": "vitest",
|
"dev": "vitest",
|
||||||
"test": "vitest",
|
"test": "vitest test",
|
||||||
"coverage": "vitest --coverage",
|
"coverage": "vitest --coverage",
|
||||||
"lint:check": "eslint ./src",
|
"lint:check": "eslint ./src",
|
||||||
"lint:fix": "eslint ./src --fix",
|
"lint:fix": "eslint ./src --fix",
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ describe('lexer', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should throw error on unexpected character', () => {
|
it('should throw error on unexpected character', () => {
|
||||||
const source = 'квест @';
|
const source = 'квест $';
|
||||||
const lexer = new Lexer(source);
|
const lexer = new Lexer(source);
|
||||||
|
|
||||||
expect(() => lexer.tokenize()).toThrow('Unexpected character');
|
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 = 'варианты',
|
OPTIONS = 'варианты',
|
||||||
TITLE = 'название',
|
TITLE = 'название',
|
||||||
|
|
||||||
|
// Module system keywords
|
||||||
|
MODULE = 'модуль',
|
||||||
|
IMPORT = 'импорт',
|
||||||
|
EXPORT = 'экспорт',
|
||||||
|
FROM = 'из',
|
||||||
|
|
||||||
// Node types
|
// Node types
|
||||||
INITIAL = 'начальный',
|
INITIAL = 'начальный',
|
||||||
ACTION = 'действие',
|
ACTION = 'действие',
|
||||||
@@ -30,6 +36,7 @@ export enum TokenType {
|
|||||||
COLON = ':',
|
COLON = ':',
|
||||||
COMMA = ',',
|
COMMA = ',',
|
||||||
DOT = '.',
|
DOT = '.',
|
||||||
|
AT = '@',
|
||||||
LEFT_BRACE = '{',
|
LEFT_BRACE = '{',
|
||||||
RIGHT_BRACE = '}',
|
RIGHT_BRACE = '}',
|
||||||
LEFT_BRACKET = '[',
|
LEFT_BRACKET = '[',
|
||||||
@@ -82,6 +89,8 @@ export interface QuestProgram extends ASTNode {
|
|||||||
name: string;
|
name: string;
|
||||||
goal: string;
|
goal: string;
|
||||||
graph: GraphNode;
|
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 {
|
export interface OptionChoice extends ASTNode {
|
||||||
type: 'OptionChoice';
|
type: 'OptionChoice';
|
||||||
text: string;
|
text: string;
|
||||||
|
// Target can be a local node id ("узел") or a module-qualified reference ("@Модуль.узел") as a raw string
|
||||||
target: string;
|
target: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,3 +160,32 @@ export interface Identifier extends ASTNode {
|
|||||||
type: 'Identifier';
|
type: 'Identifier';
|
||||||
name: string;
|
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...');
|
spinner.start('Loading quest...');
|
||||||
|
|
||||||
const source = this.readFile(filename);
|
const source = this.readFile(filename);
|
||||||
const interpreter = QuestLang.interpret(source);
|
const interpreter = QuestLang.interpret(source, filename);
|
||||||
|
|
||||||
// Validate first
|
// Validate first
|
||||||
const validation = interpreter.validate();
|
const validation = interpreter.validate();
|
||||||
@@ -237,7 +237,7 @@ class ClackCLI {
|
|||||||
spinner.start('Validating quest...');
|
spinner.start('Validating quest...');
|
||||||
|
|
||||||
const source = this.readFile(filename);
|
const source = this.readFile(filename);
|
||||||
const validation = QuestLang.validate(source);
|
const validation = QuestLang.validate(source, filename);
|
||||||
|
|
||||||
if (validation.isValid) {
|
if (validation.isValid) {
|
||||||
spinner.stop('✅ Validation completed');
|
spinner.stop('✅ Validation completed');
|
||||||
@@ -268,7 +268,7 @@ class ClackCLI {
|
|||||||
spinner.start('Analyzing quest...');
|
spinner.start('Analyzing quest...');
|
||||||
|
|
||||||
const source = this.readFile(filename);
|
const source = this.readFile(filename);
|
||||||
const interpreter = QuestLang.interpret(source);
|
const interpreter = QuestLang.interpret(source, filename);
|
||||||
const questInfo = interpreter.getQuestInfo();
|
const questInfo = interpreter.getQuestInfo();
|
||||||
|
|
||||||
spinner.stop('✅ Analysis completed');
|
spinner.stop('✅ Analysis completed');
|
||||||
|
|||||||
@@ -21,17 +21,17 @@ export class QuestLang {
|
|||||||
/**
|
/**
|
||||||
* Create interpreter from source code
|
* Create interpreter from source code
|
||||||
*/
|
*/
|
||||||
public static interpret(source: string): QuestInterpreter {
|
public static interpret(source: string, filePath?: string): QuestInterpreter {
|
||||||
const ast = this.parse(source);
|
const ast = this.parse(source);
|
||||||
return new QuestInterpreter(ast);
|
return new QuestInterpreter(ast, filePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate QuestLang source code
|
* 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 {
|
try {
|
||||||
const interpreter = this.interpret(source);
|
const interpreter = this.interpret(source, filePath);
|
||||||
return interpreter.validate();
|
return interpreter.validate();
|
||||||
}
|
}
|
||||||
catch (error) {
|
catch (error) {
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import type {
|
|||||||
OptionChoice,
|
OptionChoice,
|
||||||
QuestProgram,
|
QuestProgram,
|
||||||
} from './ast';
|
} from './ast';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { ModuleLoader } from './module-loader';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Runtime state of the quest
|
* Runtime state of the quest
|
||||||
@@ -41,14 +43,22 @@ export interface QuestVisitor {
|
|||||||
export class QuestInterpreter {
|
export class QuestInterpreter {
|
||||||
private program: QuestProgram;
|
private program: QuestProgram;
|
||||||
private currentState: QuestState;
|
private currentState: QuestState;
|
||||||
|
private moduleLoader?: ModuleLoader;
|
||||||
|
|
||||||
constructor(program: QuestProgram) {
|
constructor(program: QuestProgram, questFilePath?: string) {
|
||||||
this.program = program;
|
this.program = program;
|
||||||
this.currentState = {
|
this.currentState = {
|
||||||
currentNode: program.graph.start,
|
currentNode: program.graph.start,
|
||||||
history: [],
|
history: [],
|
||||||
isComplete: false,
|
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 {
|
public getCurrentNode(): NodeDefinition | null {
|
||||||
const nodeId = this.currentState.currentNode;
|
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
|
* Move to a specific node
|
||||||
*/
|
*/
|
||||||
public moveToNode(nodeId: string): ExecutionResult {
|
public moveToNode(nodeId: string): ExecutionResult {
|
||||||
const targetNode = this.program.graph.nodes[nodeId];
|
const targetNode = this.resolveNode(nodeId);
|
||||||
|
|
||||||
if (!targetNode) {
|
if (!targetNode) {
|
||||||
return {
|
return {
|
||||||
@@ -193,7 +203,7 @@ export class QuestInterpreter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private findPaths(nodeId: string, currentPath: string[], allPaths: string[][], visited: Set<string>): void {
|
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)) {
|
if (!node || visited.has(nodeId)) {
|
||||||
return;
|
return;
|
||||||
@@ -228,7 +238,7 @@ export class QuestInterpreter {
|
|||||||
const nodeIds = Object.keys(this.program.graph.nodes);
|
const nodeIds = Object.keys(this.program.graph.nodes);
|
||||||
|
|
||||||
// Check if start node exists
|
// 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`);
|
errors.push(`Start node '${this.program.graph.start}' does not exist`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,16 +247,36 @@ export class QuestInterpreter {
|
|||||||
if (node.nodeType === 'действие') {
|
if (node.nodeType === 'действие') {
|
||||||
const actionNode = node as ActionNode;
|
const actionNode = node as ActionNode;
|
||||||
for (const option of actionNode.options) {
|
for (const option of actionNode.options) {
|
||||||
if (!this.program.graph.nodes[option.target]) {
|
if (!this.resolveNode(option.target)) {
|
||||||
errors.push(`Node '${nodeId}' references non-existent target '${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 === 'начальный') {
|
else if (node.nodeType === 'начальный') {
|
||||||
const initialNode = node as InitialNode;
|
const initialNode = node as InitialNode;
|
||||||
for (const transition of initialNode.transitions) {
|
for (const transition of initialNode.transitions) {
|
||||||
if (!this.program.graph.nodes[transition]) {
|
if (!this.resolveNode(transition)) {
|
||||||
errors.push(`Initial node '${nodeId}' references non-existent transition '${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))
|
if (reachable.has(nodeId))
|
||||||
return;
|
return;
|
||||||
|
|
||||||
const node = this.program.graph.nodes[nodeId];
|
const node = this.resolveNode(nodeId);
|
||||||
if (!node)
|
if (!node)
|
||||||
return;
|
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.TRANSITIONS],
|
||||||
['варианты', TokenType.OPTIONS],
|
['варианты', TokenType.OPTIONS],
|
||||||
['название', TokenType.TITLE],
|
['название', TokenType.TITLE],
|
||||||
|
// Module system
|
||||||
|
['модуль', TokenType.MODULE],
|
||||||
|
['импорт', TokenType.IMPORT],
|
||||||
|
['экспорт', TokenType.EXPORT],
|
||||||
|
['из', TokenType.FROM],
|
||||||
['начальный', TokenType.INITIAL],
|
['начальный', TokenType.INITIAL],
|
||||||
['действие', TokenType.ACTION],
|
['действие', TokenType.ACTION],
|
||||||
['концовка', TokenType.ENDING],
|
['концовка', TokenType.ENDING],
|
||||||
@@ -79,6 +84,9 @@ export class Lexer {
|
|||||||
case '.':
|
case '.':
|
||||||
this.addToken(TokenType.DOT, c, start, startLine, startColumn);
|
this.addToken(TokenType.DOT, c, start, startLine, startColumn);
|
||||||
break;
|
break;
|
||||||
|
case '@':
|
||||||
|
this.addToken(TokenType.AT, c, start, startLine, startColumn);
|
||||||
|
break;
|
||||||
case '{':
|
case '{':
|
||||||
this.addToken(TokenType.LEFT_BRACE, c, start, startLine, startColumn);
|
this.addToken(TokenType.LEFT_BRACE, c, start, startLine, startColumn);
|
||||||
break;
|
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';
|
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 {
|
public parse(): QuestProgram {
|
||||||
return this.parseQuest();
|
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 {
|
private parseQuest(): QuestProgram {
|
||||||
const questToken = this.consume(TokenType.QUEST, 'Expected \'квест\'');
|
const questToken = this.consume(TokenType.QUEST, 'Expected \'квест\'');
|
||||||
const name = this.consume(TokenType.IDENTIFIER, 'Expected quest name').value;
|
const name = this.consume(TokenType.IDENTIFIER, 'Expected quest name').value;
|
||||||
this.consume(TokenType.SEMICOLON, 'Expected \';\' after quest name');
|
this.consume(TokenType.SEMICOLON, 'Expected \';\' after quest name');
|
||||||
|
|
||||||
const goal = this.parseGoal();
|
const goal = this.parseGoal();
|
||||||
|
const imports = this.parseImports();
|
||||||
const graph = this.parseGraph();
|
const graph = this.parseGraph();
|
||||||
|
|
||||||
this.consume(TokenType.END, 'Expected \'конец\'');
|
this.consume(TokenType.END, 'Expected \'конец\'');
|
||||||
@@ -39,11 +50,56 @@ export class Parser {
|
|||||||
name,
|
name,
|
||||||
goal,
|
goal,
|
||||||
graph,
|
graph,
|
||||||
|
// Only attach if there were imports to preserve older shape
|
||||||
|
...(imports.length > 0 ? { imports } : {}),
|
||||||
line: questToken.line,
|
line: questToken.line,
|
||||||
column: questToken.column,
|
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 {
|
private parseGoal(): string {
|
||||||
this.consume(TokenType.GOAL, 'Expected \'цель\'');
|
this.consume(TokenType.GOAL, 'Expected \'цель\'');
|
||||||
const goalValue = this.consume(TokenType.STRING, 'Expected goal description').value;
|
const goalValue = this.consume(TokenType.STRING, 'Expected goal description').value;
|
||||||
@@ -185,7 +241,7 @@ export class Parser {
|
|||||||
|
|
||||||
if (!this.check(TokenType.RIGHT_BRACKET)) {
|
if (!this.check(TokenType.RIGHT_BRACKET)) {
|
||||||
do {
|
do {
|
||||||
const transition = this.consume(TokenType.IDENTIFIER, 'Expected transition identifier').value;
|
const transition = this.parseTargetString();
|
||||||
transitions.push(transition);
|
transitions.push(transition);
|
||||||
} while (this.match(TokenType.COMMA));
|
} while (this.match(TokenType.COMMA));
|
||||||
}
|
}
|
||||||
@@ -203,7 +259,7 @@ export class Parser {
|
|||||||
this.consume(TokenType.LEFT_PAREN, 'Expected \'(\' for option');
|
this.consume(TokenType.LEFT_PAREN, 'Expected \'(\' for option');
|
||||||
const text = this.consume(TokenType.STRING, 'Expected option text').value;
|
const text = this.consume(TokenType.STRING, 'Expected option text').value;
|
||||||
this.consume(TokenType.COMMA, 'Expected \',\' in option');
|
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');
|
this.consume(TokenType.RIGHT_PAREN, 'Expected \')\' after option');
|
||||||
|
|
||||||
options.push({
|
options.push({
|
||||||
@@ -261,4 +317,67 @@ export class Parser {
|
|||||||
const current = this.peek();
|
const current = this.peek();
|
||||||
throw new Error(`${message}. Got ${current.type} at ${current.line}:${current.column}`);
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,11 +10,14 @@ VS Code extension for syntax highlighting of QuestLang - a specialized language
|
|||||||
- 🎨 **Color highlighting for strings and numbers**
|
- 🎨 **Color highlighting for strings and numbers**
|
||||||
- 🔧 **Automatic bracket closing**
|
- 🔧 **Automatic bracket closing**
|
||||||
- 📐 **Automatic indentation**
|
- 📐 **Automatic indentation**
|
||||||
|
- 📦 **Modules and imports** (`модуль`, `импорт`, `экспорт`, `из`)
|
||||||
|
- 🧭 **Cross-module references** `@Модуль.узел`
|
||||||
|
|
||||||
## Supported Language Elements
|
## Supported Language Elements
|
||||||
|
|
||||||
### Keywords
|
### Keywords
|
||||||
- `квест`, `цель`, `граф`, `узлы`, `начало`, `конец`
|
- `квест`, `цель`, `граф`, `узлы`, `начало`, `конец`
|
||||||
|
- `модуль`, `импорт`, `экспорт`, `из`
|
||||||
- `тип`, `описание`, `переходы`, `варианты`, `название`
|
- `тип`, `описание`, `переходы`, `варианты`, `название`
|
||||||
- `начальный`, `действие`, `концовка`
|
- `начальный`, `действие`, `концовка`
|
||||||
|
|
||||||
@@ -28,23 +31,30 @@ VS Code extension for syntax highlighting of QuestLang - a specialized language
|
|||||||
## Code Example
|
## Code Example
|
||||||
|
|
||||||
```questlang
|
```questlang
|
||||||
квест MyQuest;
|
модуль Локации;
|
||||||
цель "Quest objective description";
|
узлы {
|
||||||
|
лес: { тип: концовка; название: "Лес"; описание: "Вы в лесу"; }
|
||||||
|
}
|
||||||
|
экспорт [лес];
|
||||||
|
|
||||||
|
квест МойКвест;
|
||||||
|
цель "Описание цели квеста";
|
||||||
|
|
||||||
|
импорт Локации из "./locations.ql";
|
||||||
|
|
||||||
граф {
|
граф {
|
||||||
узлы {
|
узлы {
|
||||||
старт: {
|
старт: {
|
||||||
тип: начальный;
|
тип: начальный;
|
||||||
описание: "Beginning of the adventure";
|
описание: "Начало приключения";
|
||||||
переходы: [выбор];
|
переходы: [выбор];
|
||||||
}
|
}
|
||||||
|
|
||||||
выбор: {
|
выбор: {
|
||||||
тип: действие;
|
тип: действие;
|
||||||
описание: "What will you do?";
|
описание: "Что вы будете делать?";
|
||||||
варианты: [
|
варианты: [
|
||||||
("Go right", правый_путь),
|
("Идти в лес", @Локации.лес)
|
||||||
("Go left", левый_путь)
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,12 @@
|
|||||||
{
|
{
|
||||||
"include": "#comments"
|
"include": "#comments"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"include": "#module-declaration"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"include": "#import-declaration"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"include": "#quest-declaration"
|
"include": "#quest-declaration"
|
||||||
},
|
},
|
||||||
@@ -27,11 +33,42 @@
|
|||||||
{
|
{
|
||||||
"include": "#identifiers"
|
"include": "#identifiers"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"include": "#module-reference"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"include": "#punctuation"
|
"include": "#punctuation"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"repository": {
|
"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": {
|
"comments": {
|
||||||
"patterns": [
|
"patterns": [
|
||||||
{
|
{
|
||||||
@@ -93,7 +130,7 @@
|
|||||||
"patterns": [
|
"patterns": [
|
||||||
{
|
{
|
||||||
"name": "keyword.control.structure.questlang",
|
"name": "keyword.control.structure.questlang",
|
||||||
"match": "\\b(граф|узлы|начало|конец)\\b"
|
"match": "\\b(граф|узлы|начало|конец|модуль|импорт|экспорт|из)\\b"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "keyword.other.property.questlang",
|
"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": {
|
"strings": {
|
||||||
"patterns": [
|
"patterns": [
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user