meld
Version:
Meld: A template language for LLM prompts
578 lines (510 loc) • 20.2 kB
text/typescript
import { DirectiveNode, MeldNode, TextNode } from 'meld-spec';
import type { DirectiveContext, IDirectiveHandler } from '@services/pipeline/DirectiveService/IDirectiveService.js';
import type { DirectiveResult } from '@services/pipeline/DirectiveService/types.js';
import { IValidationService } from '@services/resolution/ValidationService/IValidationService.js';
import { IStateService } from '@services/state/StateService/IStateService.js';
import { IResolutionService, StructuredPath } from '@services/resolution/ResolutionService/IResolutionService.js';
import { IFileSystemService } from '@services/fs/FileSystemService/IFileSystemService.js';
import { IParserService } from '@services/pipeline/ParserService/IParserService.js';
import { IInterpreterService } from '@services/pipeline/InterpreterService/IInterpreterService.js';
import { ICircularityService } from '@services/resolution/CircularityService/ICircularityService.js';
import { DirectiveError, DirectiveErrorCode, DirectiveErrorSeverity } from '@services/pipeline/DirectiveService/errors/DirectiveError.js';
import { directiveLogger as logger } from '@core/utils/logger.js';
import { ErrorSeverity } from '@core/errors/MeldError.js';
import { IStateTrackingService } from '@tests/utils/debug/StateTrackingService/IStateTrackingService.js';
import { StateVariableCopier } from '@services/state/utilities/StateVariableCopier.js';
/**
* Handler for @import directives
* Imports variables from other Meld files
*/
export class ImportDirectiveHandler implements IDirectiveHandler {
readonly kind = 'import';
private debugEnabled: boolean = false;
private stateTrackingService?: IStateTrackingService;
private stateVariableCopier: StateVariableCopier;
constructor(
private validationService: IValidationService,
private resolutionService: IResolutionService,
private stateService: IStateService,
private fileSystemService: IFileSystemService,
private parserService: IParserService,
private interpreterService: IInterpreterService,
private circularityService: ICircularityService,
trackingService?: IStateTrackingService
) {
this.stateTrackingService = trackingService;
this.debugEnabled = !!trackingService && (process.env.MELD_DEBUG === 'true');
this.stateVariableCopier = new StateVariableCopier(trackingService);
}
async execute(node: DirectiveNode, context: DirectiveContext): Promise<DirectiveResult | IStateService> {
let resolvedFullPath: string | undefined;
let targetState: IStateService;
try {
// 1. Validate directive structure
await this.validationService.validate(node);
// 2. Extract path and imports
const { path, imports } = node.directive;
// 3. Process path
if (!path) {
throw new DirectiveError(
'Import directive requires a path',
this.kind,
DirectiveErrorCode.VALIDATION_FAILED
);
}
// Use the context state directly for transformation mode
targetState = context.state;
// Resolve variables in path
const resolutionContext = {
currentFilePath: context.currentFilePath,
state: context.state,
allowedVariableTypes: {
text: true,
data: true,
path: true,
command: false
}
};
resolvedFullPath = await this.resolutionService.resolveInContext(
path,
resolutionContext
);
if (!resolvedFullPath) {
throw new DirectiveError(
`Could not resolve path: ${path}`,
this.kind,
DirectiveErrorCode.VARIABLE_NOT_FOUND
);
}
// Check if the file exists
const fileExists = await this.fileSystemService.exists(resolvedFullPath);
if (!fileExists) {
throw new DirectiveError(
`File not found: ${resolvedFullPath}`,
this.kind,
DirectiveErrorCode.FILE_NOT_FOUND
);
}
// Check for circular imports
try {
this.circularityService.beginImport(resolvedFullPath);
} catch (error: any) {
// Rethrow as a directive error
throw new DirectiveError(
`Circular import detected: ${error.message}`,
this.kind,
DirectiveErrorCode.CIRCULAR_REFERENCE
);
}
// Read the file
const fileContent = await this.fileSystemService.readFile(resolvedFullPath);
// Register the source file with source mapping service if available
try {
const { registerSource, addMapping } = require('@core/utils/sourceMapUtils.js');
// Register the source file content
registerSource(resolvedFullPath, fileContent);
// Add a mapping from the first line of the source file to the location of the import directive
if (node.location && node.location.start) {
addMapping(
resolvedFullPath,
1, // Start at line 1 of the imported file
1, // Start at column 1
node.location.start.line,
node.location.start.column
);
logger.debug(`Added source mapping from ${resolvedFullPath}:1:1 to line ${node.location.start.line}:${node.location.start.column}`);
}
} catch (err) {
// Source mapping is optional, so just log a debug message if it fails
logger.debug('Source mapping not available, skipping', { error: err });
}
// Parse the file
const nodes = await this.parserService.parse(fileContent);
// Create a child state for the imported file
const importedState = context.state.createChildState();
try {
if (resolvedFullPath) {
importedState.setCurrentFilePath(resolvedFullPath);
}
} catch (error) {
logger.warn('Failed to set current file path on imported state', {
resolvedFullPath,
error: error instanceof Error ? error.message : String(error)
});
}
// Interpret the file
const resultState = await this.interpreterService.interpret(nodes, {
initialState: importedState,
filePath: resolvedFullPath
});
// Process imports
if (imports) {
// Resolve variables in imports if it's a string
if (typeof imports === 'string') {
const resolvedImports = await this.resolutionService.resolveInContext(imports, resolutionContext);
// Parse the import list
const parsedImports = this.parseImportList(resolvedImports);
// Process the structured imports
this.processStructuredImports(parsedImports, resultState, targetState);
} else if (Array.isArray(imports)) {
// If imports is already an array of ImportItem objects, use it directly
this.processStructuredImports(imports, resultState, targetState);
} else {
// Handle unexpected type
throw new DirectiveError(
`Import directive has invalid imports format: ${typeof imports}`,
this.kind,
DirectiveErrorCode.VALIDATION_FAILED
);
}
} else {
// No import list - import all variables
this.importAllVariables(resultState, targetState);
}
// End import tracking
if (resolvedFullPath) {
this.circularityService.endImport(resolvedFullPath);
}
// Check if transformation is enabled
if (targetState.isTransformationEnabled && targetState.isTransformationEnabled()) {
// Replace the directive with empty content
const replacement: TextNode = {
type: 'Text',
content: '',
location: node.location ? {
start: node.location.start,
end: node.location.end
} : undefined
};
// IMPORTANT: Copy variables from imported state to parent state
// even in transformation mode
if (context.parentState) {
// Copy all text variables from the imported state to the parent state
const textVars = targetState.getAllTextVars();
textVars.forEach((value, key) => {
if (context.parentState) {
context.parentState.setTextVar(key, value);
}
});
// Copy all data variables from the imported state to the parent state
const dataVars = targetState.getAllDataVars();
dataVars.forEach((value, key) => {
if (context.parentState) {
context.parentState.setDataVar(key, value);
}
});
// Copy all path variables from the imported state to the parent state
const pathVars = targetState.getAllPathVars();
pathVars.forEach((value, key) => {
if (context.parentState) {
context.parentState.setPathVar(key, value);
}
});
// Copy all commands from the imported state to the parent state
const commands = targetState.getAllCommands();
commands.forEach((value, key) => {
if (context.parentState) {
context.parentState.setCommand(key, value);
}
});
}
// Add the original imported variables to the context state as well
// This ensures variables are available in the current context
const textVars = targetState.getAllTextVars();
textVars.forEach((value, key) => {
context.state.setTextVar(key, value);
});
const dataVars = targetState.getAllDataVars();
dataVars.forEach((value, key) => {
context.state.setDataVar(key, value);
});
const pathVars = targetState.getAllPathVars();
pathVars.forEach((value, key) => {
context.state.setPathVar(key, value);
});
const commands = targetState.getAllCommands();
commands.forEach((value, key) => {
context.state.setCommand(key, value);
});
return {
state: targetState,
replacement
};
} else {
// If parent state exists, copy all variables back to it
if (context.parentState) {
// Copy all text variables from the imported state to the parent state
const textVars = targetState.getAllTextVars();
textVars.forEach((value, key) => {
if (context.parentState) {
context.parentState.setTextVar(key, value);
}
});
// Copy all data variables from the imported state to the parent state
const dataVars = targetState.getAllDataVars();
dataVars.forEach((value, key) => {
if (context.parentState) {
context.parentState.setDataVar(key, value);
}
});
// Copy all path variables from the imported state to the parent state
const pathVars = targetState.getAllPathVars();
pathVars.forEach((value, key) => {
if (context.parentState) {
context.parentState.setPathVar(key, value);
}
});
// Copy all commands from the imported state to the parent state
const commands = targetState.getAllCommands();
commands.forEach((value, key) => {
if (context.parentState) {
context.parentState.setCommand(key, value);
}
});
}
// Log the import operation
logger.debug('Import complete', {
path: resolvedFullPath,
imports,
targetState
});
return targetState;
}
} catch (error: unknown) {
// Handle errors
let errorObj: DirectiveError;
if (!(error instanceof DirectiveError)) {
// For specific error types, create standardized DirectiveError with expected messages
const errorMessage = error instanceof Error ? error.message : String(error);
if (error instanceof Error && error.name === 'MeldResolutionError' && errorMessage.includes('Variable not found')) {
errorObj = new DirectiveError(
errorMessage,
this.kind,
DirectiveErrorCode.VARIABLE_NOT_FOUND
);
} else if (errorMessage === 'Parse error') {
errorObj = new DirectiveError(
errorMessage,
this.kind,
DirectiveErrorCode.EXECUTION_FAILED
);
} else if (errorMessage === 'Interpretation error') {
errorObj = new DirectiveError(
errorMessage,
this.kind,
DirectiveErrorCode.EXECUTION_FAILED
);
} else if (errorMessage === 'Read error') {
errorObj = new DirectiveError(
errorMessage,
this.kind,
DirectiveErrorCode.FILE_NOT_FOUND
);
} else {
// Generic wrapper for other error types
errorObj = new DirectiveError(
`Import directive error: ${errorMessage}`,
this.kind,
DirectiveErrorCode.EXECUTION_FAILED,
{
cause: error instanceof Error ? error : undefined
}
);
}
} else {
errorObj = error;
}
// End import tracking if necessary
if (resolvedFullPath) {
try {
this.circularityService.endImport(resolvedFullPath);
} catch (cleanupError) {
logger.warn('Error during import cleanup', { error: cleanupError });
}
}
// Always throw the error, even in transformation mode
throw errorObj;
}
}
private processImportList(importList: string, sourceState: IStateService, targetState: IStateService): void {
// Parse the import list and process it
const importItems = this.parseImportList(importList);
this.processStructuredImports(importItems, sourceState, targetState);
}
private parseImportList(importList: string | Array<{ name: string; alias?: string }>): Array<{ name: string; alias?: string }> {
// Handle undefined or null importList
if (!importList) {
return [{ name: '*' }]; // Default to importing everything
}
// If importList is already an array, return it directly
if (Array.isArray(importList)) {
return importList;
}
// Ensure importList is a string
if (typeof importList !== 'string') {
throw new DirectiveError(
`Import list must be a string or array, got ${typeof importList}`,
this.kind,
DirectiveErrorCode.VALIDATION_FAILED
);
}
// Split by commas, but handle potential quoted strings
const result: Array<{ name: string; alias?: string }> = [];
// Simple split for now, might need more robust parsing later
const parts = importList.split(',').map(p => p.trim());
for (const part of parts) {
// Check for 'as' keyword to identify aliases
if (part.includes(' as ')) {
// Format: "name as alias"
const [name, alias] = part.split(' as ').map(p => p.trim());
result.push({ name, alias });
} else {
// Just a name without alias
result.push({ name: part });
}
}
return result;
}
private importAllVariables(sourceState: IStateService, targetState: IStateService): void {
this.stateVariableCopier.copyAllVariables(sourceState, targetState, {
skipExisting: false,
trackContextBoundary: true,
trackVariableCrossing: true
});
}
private importVariable(name: string, alias: string | undefined, sourceState: IStateService, targetState: IStateService): void {
// Use the StateVariableCopier to copy a specific variable
const variablesCopied = this.stateVariableCopier.copySpecificVariables(
sourceState,
targetState,
[{ name, alias }],
{
skipExisting: false,
trackContextBoundary: true,
trackVariableCrossing: true
}
);
// If no variables were copied, throw an error
if (variablesCopied === 0) {
throw new DirectiveError(
`Variable "${name}" not found in imported file`,
this.kind,
DirectiveErrorCode.VARIABLE_NOT_FOUND
);
}
}
/**
* Track context boundary between states
*/
private trackContextBoundary(sourceState: IStateService, targetState: IStateService, filePath?: string): void {
if (!this.debugEnabled || !this.stateTrackingService) {
return;
}
try {
const sourceId = sourceState.getStateId();
const targetId = targetState.getStateId();
if (!sourceId || !targetId) {
logger.debug('Cannot track context boundary - missing state ID', {
source: sourceState,
target: targetState
});
return;
}
logger.debug('Tracking context boundary', {
sourceId,
targetId,
filePath
});
// Call the tracking service method - we know it exists on the implementation
// even though it's not in the interface
(this.stateTrackingService as any).trackContextBoundary(
sourceId,
targetId,
'import',
filePath || ''
);
} catch (error) {
// Don't let tracking errors affect normal operation
logger.debug('Error tracking context boundary', { error });
}
}
/**
* Track variable copying between contexts
*/
private trackVariableCrossing(
variableName: string,
variableType: 'text' | 'data' | 'path' | 'command',
sourceState: IStateService,
targetState: IStateService,
alias?: string
): void {
if (!this.debugEnabled || !this.stateTrackingService) {
return;
}
try {
const sourceId = sourceState.getStateId();
const targetId = targetState.getStateId();
if (!sourceId || !targetId) {
logger.debug('Cannot track variable crossing - missing state ID', {
source: sourceState,
target: targetState
});
return;
}
logger.debug('Tracking variable crossing', {
variableName,
variableType,
sourceId,
targetId,
alias
});
// Call the tracking service method - we know it exists on the implementation
// even though it's not in the interface
(this.stateTrackingService as any).trackVariableCrossing(
sourceId,
targetId,
variableName,
variableType,
alias
);
} catch (error) {
// Don't let tracking errors affect normal operation
logger.debug('Error tracking variable crossing', { error });
}
}
private processStructuredImports(
imports: Array<{ name: string; alias?: string }>,
sourceState: IStateService,
targetState: IStateService
): void {
// Add at the beginning of the method
// Track the context boundary between source and target states
let filePath: string | null | undefined = null;
try {
filePath = sourceState.getCurrentFilePath();
} catch (error) {
// Handle the case where getCurrentFilePath is not available
logger.debug('Error getting current file path', { error });
}
this.trackContextBoundary(sourceState, targetState, filePath ? filePath : undefined);
// If imports is empty or contains a wildcard, import everything
if (imports.length === 0 || imports.some(i => i.name === '*')) {
this.importAllVariables(sourceState, targetState);
return;
}
// Import each variable individually
for (const item of imports) {
try {
this.importVariable(item.name, item.alias, sourceState, targetState);
} catch (error) {
if (error instanceof DirectiveError && error.code === DirectiveErrorCode.VARIABLE_NOT_FOUND) {
// Log warning but continue with other imports
logger.warn(`Import warning: ${error.message}`);
} else {
// Re-throw other errors
throw error;
}
}
}
}
}