diff --git a/src/__tests__/modules.test.ts b/src/__tests__/modules.test.ts index 302a1f5..8c2dbc6 100644 --- a/src/__tests__/modules.test.ts +++ b/src/__tests__/modules.test.ts @@ -54,7 +54,11 @@ describe('module system', () => { `; 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(); 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(); expect(validation.isValid).toBe(true); }); @@ -133,7 +141,11 @@ describe('module system', () => { `; 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(); expect(validation.isValid).toBe(false); expect(validation.errors.some(e => e.includes('non-existent') || e.includes('not exported'))).toBe(true); diff --git a/src/cli.ts b/src/cli.ts index 88fe7b8..6957a80 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -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, 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 const validation = interpreter.validate(); @@ -237,7 +242,11 @@ class ClackCLI { spinner.start('Validating quest...'); 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) { spinner.stop('✅ Validation completed'); @@ -268,7 +277,11 @@ class ClackCLI { spinner.start('Analyzing quest...'); 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(); spinner.stop('✅ Analysis completed'); diff --git a/src/index.ts b/src/index.ts index b6ffd5c..a60aaef 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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, filePath?: string): QuestInterpreter { + public static interpret(source: string, filePath?: string, host?: ModuleHost): QuestInterpreter { const ast = this.parse(source); - return new QuestInterpreter(ast, filePath); + return new QuestInterpreter(ast, filePath, host); } /** * 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 { - const interpreter = this.interpret(source, filePath); + 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'; diff --git a/src/interpreter.ts b/src/interpreter.ts index 4f7e3ae..1291e7c 100644 --- a/src/interpreter.ts +++ b/src/interpreter.ts @@ -6,8 +6,8 @@ import type { OptionChoice, QuestProgram, } from './ast'; -import path from 'node:path'; import { ModuleLoader } from './module-loader'; +import type { ModuleHost } from './module-loader'; /** * Runtime state of the quest @@ -45,7 +45,7 @@ export class QuestInterpreter { private currentState: QuestState; private moduleLoader?: ModuleLoader; - constructor(program: QuestProgram, questFilePath?: string) { + constructor(program: QuestProgram, questFilePath?: string, host?: ModuleHost) { this.program = program; this.currentState = { currentNode: program.graph.start, @@ -54,8 +54,8 @@ export class QuestInterpreter { }; // 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)); + if (questFilePath && host && program.imports && program.imports.length > 0) { + this.moduleLoader = new ModuleLoader(host); // Load modules this.moduleLoader.loadQuest(questFilePath); } diff --git a/src/module-loader.ts b/src/module-loader.ts index 53145f0..074e433 100644 --- a/src/module-loader.ts +++ b/src/module-loader.ts @@ -1,9 +1,12 @@ 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 interface ModuleHost { + readFile(file: string): string; + resolve(fromFile: string, specifier: string): string; +} + export enum VisitState { Unvisited, Visiting, @@ -23,17 +26,17 @@ export class ModuleLoader { private byFile = new Map(); private visit = new Map(); - constructor(private baseDir: string) {} + constructor(private host: ModuleHost) {} 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); // Parse imports (if any) const imports: ImportNode[] = program.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); } @@ -52,14 +55,14 @@ export class ModuleLoader { this.visit.set(file, VisitState.Visiting); if (!this.byFile.has(file)) { - const src = fs.readFileSync(file, 'utf8'); + 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 = path.resolve(path.dirname(file), imp.modulePath); + const dep = this.host.resolve(file, imp.modulePath); this.dfsParse(dep); } }