feat: enhance module system with host interface for file operations

This commit is contained in:
2025-11-15 21:02:14 +07:00
parent eadf1b9a73
commit 0add5faa43
5 changed files with 51 additions and 21 deletions

View File

@@ -54,7 +54,11 @@ describe('module system', () => {
`; `;
fs.writeFileSync(questPath, quest); fs.writeFileSync(questPath, quest);
const interpreter = QuestLang.interpret(quest, questPath); 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(); const validation = interpreter.validate();
expect(validation.isValid).toBe(true); expect(validation.isValid).toBe(true);
}); });
@@ -100,7 +104,11 @@ describe('module system', () => {
конец; конец;
`); `);
const interpreter = QuestLang.interpret(fs.readFileSync(qPath, 'utf8'), qPath); 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(); const validation = interpreter.validate();
expect(validation.isValid).toBe(true); expect(validation.isValid).toBe(true);
}); });
@@ -133,7 +141,11 @@ describe('module system', () => {
`; `;
fs.writeFileSync(questPath, quest); fs.writeFileSync(questPath, quest);
const interpreter = QuestLang.interpret(quest, questPath); 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(); const validation = interpreter.validate();
expect(validation.isValid).toBe(false); expect(validation.isValid).toBe(false);
expect(validation.errors.some(e => e.includes('non-existent') || e.includes('not exported'))).toBe(true); expect(validation.errors.some(e => e.includes('non-existent') || e.includes('not exported'))).toBe(true);

View File

@@ -3,6 +3,7 @@
import type { QuestInterpreter } from '.'; import type { QuestInterpreter } from '.';
import type { InitialNode, NodeDefinition } from './ast.js'; import type { InitialNode, NodeDefinition } from './ast.js';
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path';
import process from 'node:process'; import process from 'node:process';
import * as p from '@clack/prompts'; import * as p from '@clack/prompts';
import { QuestLang } from '.'; import { QuestLang } from '.';
@@ -122,7 +123,11 @@ 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, filename); 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 // Validate first
const validation = interpreter.validate(); const validation = interpreter.validate();
@@ -237,7 +242,11 @@ 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, filename); 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) { if (validation.isValid) {
spinner.stop('✅ Validation completed'); spinner.stop('✅ Validation completed');
@@ -268,7 +277,11 @@ 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, filename); 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(); const questInfo = interpreter.getQuestInfo();
spinner.stop('✅ Analysis completed'); spinner.stop('✅ Analysis completed');

View File

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

View File

@@ -6,8 +6,8 @@ import type {
OptionChoice, OptionChoice,
QuestProgram, QuestProgram,
} from './ast'; } from './ast';
import path from 'node:path';
import { ModuleLoader } from './module-loader'; import { ModuleLoader } from './module-loader';
import type { ModuleHost } from './module-loader';
/** /**
* Runtime state of the quest * Runtime state of the quest
@@ -45,7 +45,7 @@ export class QuestInterpreter {
private currentState: QuestState; private currentState: QuestState;
private moduleLoader?: ModuleLoader; private moduleLoader?: ModuleLoader;
constructor(program: QuestProgram, questFilePath?: string) { constructor(program: QuestProgram, questFilePath?: string, host?: ModuleHost) {
this.program = program; this.program = program;
this.currentState = { this.currentState = {
currentNode: program.graph.start, currentNode: program.graph.start,
@@ -54,8 +54,8 @@ export class QuestInterpreter {
}; };
// Initialize module loader if imports present and quest file path provided // Initialize module loader if imports present and quest file path provided
if (questFilePath && program.imports && program.imports.length > 0) { if (questFilePath && host && program.imports && program.imports.length > 0) {
this.moduleLoader = new ModuleLoader(path.dirname(questFilePath)); this.moduleLoader = new ModuleLoader(host);
// Load modules // Load modules
this.moduleLoader.loadQuest(questFilePath); this.moduleLoader.loadQuest(questFilePath);
} }

View File

@@ -1,9 +1,12 @@
import type { ImportNode, ModuleNode, QuestProgram } from './ast'; import type { ImportNode, ModuleNode, QuestProgram } from './ast';
import fs from 'node:fs';
import path from 'node:path';
import { Lexer } from './lexer'; import { Lexer } from './lexer';
import { Parser } from './parser'; import { Parser } from './parser';
export interface ModuleHost {
readFile(file: string): string;
resolve(fromFile: string, specifier: string): string;
}
export enum VisitState { export enum VisitState {
Unvisited, Unvisited,
Visiting, Visiting,
@@ -23,17 +26,17 @@ export class ModuleLoader {
private byFile = new Map<string, LoadedModule>(); private byFile = new Map<string, LoadedModule>();
private visit = new Map<string, VisitState>(); private visit = new Map<string, VisitState>();
constructor(private baseDir: string) {} constructor(private host: ModuleHost) {}
public loadQuest(questFile: string): { program: QuestProgram; modules: LoadedModule[] } { public loadQuest(questFile: string): { program: QuestProgram; modules: LoadedModule[] } {
const source = fs.readFileSync(questFile, 'utf8'); const source = this.host.readFile(questFile);
const program = this.parseQuest(source); const program = this.parseQuest(source);
// Parse imports (if any) // Parse imports (if any)
const imports: ImportNode[] = program.imports || []; const imports: ImportNode[] = program.imports || [];
for (const imp of imports) { for (const imp of imports) {
const abs = path.resolve(path.dirname(questFile), imp.modulePath); const abs = this.host.resolve(questFile, imp.modulePath);
this.dfsParse(abs); this.dfsParse(abs);
} }
@@ -52,14 +55,14 @@ export class ModuleLoader {
this.visit.set(file, VisitState.Visiting); this.visit.set(file, VisitState.Visiting);
if (!this.byFile.has(file)) { if (!this.byFile.has(file)) {
const src = fs.readFileSync(file, 'utf8'); const src = this.host.readFile(file);
const ast = this.parseModule(src, file); const ast = this.parseModule(src, file);
this.byFile.set(file, { name: ast.name, file, ast }); this.byFile.set(file, { name: ast.name, file, ast });
// Follow module's own imports (if any) // Follow module's own imports (if any)
const ownImports = ast.imports || []; const ownImports = ast.imports || [];
for (const imp of ownImports) { for (const imp of ownImports) {
const dep = path.resolve(path.dirname(file), imp.modulePath); const dep = this.host.resolve(file, imp.modulePath);
this.dfsParse(dep); this.dfsParse(dep);
} }
} }