meld
Version:
Meld: A template language for LLM prompts
1,340 lines (1,183 loc) • 46.8 kB
text/typescript
import type { IStateService } from '@services/state/StateService/IStateService.js';
import type { ResolutionContext } from '@services/resolution/ResolutionService/IResolutionService.js';
import { ResolutionErrorCode } from '@services/resolution/ResolutionService/IResolutionService.js';
import { MeldResolutionError } from '@core/errors/MeldResolutionError.js';
import { ErrorSeverity } from '@core/errors/MeldError.js';
import type { IResolutionService } from '@services/resolution/ResolutionService/IResolutionService.js';
import type { IParserService } from '@services/pipeline/ParserService/IParserService.js';
import type { MeldNode, TextNode, DirectiveNode, TextVarNode, DataVarNode } from 'meld-spec';
import { resolutionLogger as logger } from '@core/utils/logger.js';
import { VariableResolutionTracker } from '@tests/utils/debug/VariableResolutionTracker/index.js';
// Define the field type for clarity
interface Field {
type: 'field' | 'index';
value: string | number;
}
/**
* Handles resolution of variable references ({{var}})
* Previously used ${var} for text and #{var} for data, now unified as {{var}}
*/
export class VariableReferenceResolver {
private readonly MAX_RESOLUTION_DEPTH = 10;
private readonly MAX_ITERATIONS = 100;
private resolutionTracker?: VariableResolutionTracker;
constructor(
private readonly stateService: IStateService,
private readonly resolutionService?: IResolutionService,
private readonly parserService?: IParserService
) {}
/**
* Set the resolution tracker for debugging
* @internal
*/
setResolutionTracker(tracker: VariableResolutionTracker): void {
this.resolutionTracker = tracker;
}
/**
* Resolves all variable references in the given text
* @param text Text containing variable references like {{varName}}
* @param context Resolution context
* @returns Resolved text with all variables replaced with their values
*/
async resolve(content: string, context: ResolutionContext): Promise<string> {
if (!content) {
logger.debug('Empty content provided to variable resolver');
return content;
}
logger.debug('Resolving content:', {
content,
hasState: !!context.state,
currentFilePath: context.currentFilePath,
transformationEnabled: context.state?.isTransformationEnabled?.() ?? true
});
// Check if content contains variable references
if (!content.includes('{{')) {
return content;
}
// Use regex to find all variable references in the content
const variableRegex = /\{\{([^{}]+)\}\}/g;
let result = content;
let matches = Array.from(content.matchAll(variableRegex));
// If no matches, return original content
if (matches.length === 0) {
return content;
}
logger.debug(`Found ${matches.length} variable references in content`);
// Process each variable reference
for (const match of matches) {
const fullMatch = match[0]; // The entire match, e.g., {{variable.field}}
const reference = match[1].trim(); // The variable reference, e.g., variable.field
try {
// Special handling for environment variables
if (reference.startsWith('ENV_')) {
throw new MeldResolutionError(
`Variable ${reference} not found`,
{
code: ResolutionErrorCode.UNDEFINED_VARIABLE,
details: { variableName: reference },
severity: ErrorSeverity.Recoverable
}
);
}
// Split the reference into variable name and field path
const [variableName, ...fieldParts] = reference.split('.');
const fieldPath = fieldParts.length > 0 ? fieldParts.join('.') : '';
logger.debug('Processing variable reference:', {
fullMatch,
variableName,
fieldPath
});
// Use the resolveFieldAccess method to handle field access
const value = await this.resolveFieldAccess(variableName, fieldPath, context);
if (value !== undefined) {
// Replace the variable reference with its value
const stringValue = typeof value === 'string' ? value : JSON.stringify(value);
result = result.replace(fullMatch, stringValue);
logger.debug('Resolved variable reference:', {
fullMatch,
value: stringValue
});
} else {
// Always throw for undefined variables to match test expectations
throw new MeldResolutionError(
`Variable '${variableName}' not found`,
{
code: ResolutionErrorCode.UNDEFINED_VARIABLE,
details: { variableName },
severity: ErrorSeverity.Recoverable
}
);
}
} catch (error) {
// Handle errors during variable resolution
logger.error('Error resolving variable reference:', {
fullMatch,
reference,
error
});
// Always rethrow errors to match test expectations
throw error;
}
}
logger.debug('Final resolved content:', result);
return result;
}
/**
* Resolves a list of nodes, handling variable references
* @param nodes The nodes to resolve
* @param context The resolution context
* @returns The resolved content
*/
async resolveNodes(nodes: MeldNode[], context: ResolutionContext): Promise<string> {
let result = '';
// Track the resolution path to detect circular references
const resolutionPath: string[] = [];
logger.debug('Resolving nodes:', {
nodeCount: nodes.length,
nodeTypes: nodes.map(n => n.type),
transformationEnabled: context.state?.isTransformationEnabled?.() ?? true
});
for (const node of nodes) {
logger.debug('Processing node:', {
type: node.type,
content: node.type === 'Text' ? (node as TextNode).content :
node.type === 'TextVar' ? (node as TextVarNode).identifier :
JSON.stringify(node)
});
if (node.type === 'Text') {
const textNode = node as TextNode;
// Always check for variable references in text nodes
if (textNode.content.includes('{{')) {
const resolved = await this.resolve(textNode.content, context);
logger.debug('Resolved text node content:', {
original: textNode.content,
resolved
});
result += resolved;
} else {
result += textNode.content;
}
} else if (node.type === 'TextVar') {
// Handle text variable nodes
try {
const textVarNode = node as TextVarNode;
const identifier = textVarNode.identifier;
logger.debug('Processing TextVar node:', {
identifier,
transformationEnabled: context.state?.isTransformationEnabled?.() ?? true
});
// Always try to resolve the variable
const value = await this.getVariable(identifier, context);
logger.debug('Resolved TextVar value:', {
identifier,
value
});
if (value !== undefined) {
result += String(value);
} else if (context.strict !== false) {
throw new MeldResolutionError(
`Variable ${identifier} not found`,
{
code: ResolutionErrorCode.UNDEFINED_VARIABLE,
details: { variableName: identifier },
severity: ErrorSeverity.Recoverable
}
);
} else {
// Keep the variable reference if in permissive mode and value not found
result += `{{${identifier}}}`;
}
} catch (error) {
logger.error('Error resolving TextVar node:', {
node: JSON.stringify(node),
error
});
if (context.strict) {
throw error;
} else {
// Keep the variable reference if error occurs in permissive mode
result += `{{${(node as TextVarNode).identifier}}}`;
}
}
} else if (node.type === 'DataVar') {
// Handle data variable nodes
try {
const dataVarNode = node as DataVarNode;
const identifier = dataVarNode.identifier;
const fields = dataVarNode.fields || [];
logger.debug('Processing DataVar node:', {
identifier,
fields,
transformationEnabled: context.state?.isTransformationEnabled?.() ?? true
});
// Always try to resolve the variable
const fieldPath = fields.map(f => {
// Handle different field types safely
if (typeof f === 'string') {
return f;
} else if (f && typeof f === 'object') {
// Use type assertion to access properties safely
const field = f as { type?: string; value?: string | number };
return field.value !== undefined ? String(field.value) : '';
}
return '';
}).filter(Boolean).join('.');
const value = await this.resolveFieldAccess(identifier, fieldPath, context);
logger.debug('Resolved DataVar value:', {
identifier,
fieldPath,
value
});
if (value !== undefined) {
result += typeof value === 'string' ? value : JSON.stringify(value);
} else if (context.strict !== false) {
throw new MeldResolutionError(
`Variable ${identifier} not found`,
{
code: ResolutionErrorCode.UNDEFINED_VARIABLE,
details: { variableName: identifier },
severity: ErrorSeverity.Recoverable
}
);
} else {
// Keep the variable reference if in permissive mode and value not found
result += `{{${identifier}${fieldPath ? '.' + fieldPath : ''}}}`;
}
} catch (error) {
logger.error('Error resolving DataVar node:', {
node: JSON.stringify(node),
error
});
if (context.strict) {
throw error;
} else {
// Keep the variable reference if error occurs in permissive mode
const dataVarNode = node as DataVarNode;
const identifier = dataVarNode.identifier;
const fields = dataVarNode.fields || [];
const fieldPath = fields.map(f => {
// Handle different field types safely
if (typeof f === 'string') {
return f;
} else if (f && typeof f === 'object') {
// Use type assertion to access properties safely
const field = f as { type?: string; value?: string | number };
return field.value !== undefined ? String(field.value) : '';
}
return '';
}).filter(Boolean).join('.');
result += `{{${identifier}${fieldPath ? '.' + fieldPath : ''}}}`;
}
}
} else {
// For other node types, just convert to string
result += JSON.stringify(node);
}
}
logger.debug('Final resolved nodes result:', result);
return result;
}
/**
* Extract the actual value from a node, not just its string representation
*/
private getNodeValue(node: MeldNode, context: ResolutionContext): string {
// Different handling based on node type
switch (node.type) {
case 'Text':
return (node as TextNode).content;
case 'TextVar':
const textVarNode = node as any;
const textVar = textVarNode.identifier;
const value = context.state?.getTextVar(textVar) || this.stateService.getTextVar(textVar);
return value !== undefined ? String(value) : '';
case 'DataVar':
const dataVarNode = node as any;
const dataVar = dataVarNode.identifier;
const dataValue = context.state?.getDataVar(dataVar) || this.stateService.getDataVar(dataVar);
// For data variables, return JSON string for objects
return dataValue !== undefined
? (typeof dataValue === 'object' ? JSON.stringify(dataValue) : String(dataValue))
: '';
case 'PathVar':
const pathVarNode = node as any;
const pathVar = pathVarNode.identifier;
const stateToUse = context.state || this.stateService;
// Handle special path variables
if (pathVar === 'PROJECTPATH' || pathVar === '.') {
return stateToUse.getPathVar('PROJECTPATH') || '';
} else if (pathVar === 'HOMEPATH' || pathVar === '~') {
return stateToUse.getPathVar('HOMEPATH') || '';
}
// Regular path variable
return stateToUse.getPathVar(pathVar) || '';
case 'CodeFence':
const codeFence = node as any;
return codeFence.content || '';
default:
// For unsupported node types, return empty string
return '';
}
}
/**
* Convert a node to string representation
* @deprecated Use getNodeValue instead for actual variable values
*/
private nodeToString(node: MeldNode): string {
if (node.type === 'TextVar') {
const textVarNode = node as TextVarNode;
return `{{${textVarNode.identifier}}}`;
} else if (node.type === 'DataVar') {
const dataVarNode = node as DataVarNode;
let result = dataVarNode.identifier;
// Append fields/indices if present
if (dataVarNode.fields && dataVarNode.fields.length > 0) {
for (const field of dataVarNode.fields) {
const typedField = field as unknown as Field;
if (typedField.type === 'field') {
result += `.${typedField.value}`;
} else if (typedField.type === 'index') {
result += `[${typedField.value}]`;
}
}
}
return `{{${result}}}`;
}
return '';
}
/**
* Resolve text with variable references
*/
private async resolveText(
text: string,
context: ResolutionContext,
resolutionPath: string[] = []
): Promise<string> {
// Wrap any console.log statements in DEBUG checks
if (process.env.DEBUG === 'true') {
console.log('*** resolveText context state:', {
stateExists: !!context.state,
stateMethods: context.state ? Object.keys(context.state) : 'undefined',
text
});
}
if (!text) {
return '';
}
let result = text;
try {
// Parse the text to find variable nodes
const nodes = await this.parserService?.parse(text);
const hasVariables = nodes?.some(node =>
node.type === 'TextVar' ||
node.type === 'DataVar' ||
node.type === 'PathVar'
);
if (!hasVariables) {
return result;
}
// Process each variable node
return await this.resolveWithAst(result, context);
} catch (error) {
// If parsing fails, return the original text
console.warn('Failed to parse text for variable resolution:', error);
return result;
}
}
/**
* Resolves a variable node (TextVar or DataVar)
* @param node The variable node to resolve
* @param context The resolution context
* @param resolutionPath Path to detect circular references
* @returns The resolved value
*/
private async resolveVarNode(
node: MeldNode,
context: ResolutionContext,
resolutionPath: string[] = []
): Promise<any> {
// Normalize the variable node structure
const normalized = this.normalizeVarNode(node);
if (!normalized) {
throw new MeldResolutionError(
`Unsupported variable node type: ${node.type}`,
{
code: ResolutionErrorCode.RESOLUTION_FAILED,
details: { variableName: node.type },
severity: ErrorSeverity.Recoverable
}
);
}
// Get the base variable value
let value = await this.getVariable(normalized.identifier, context);
if (value === undefined) {
if (context.strict !== false) {
throw new MeldResolutionError(
`Undefined variable: ${normalized.identifier}`,
{
code: ResolutionErrorCode.UNDEFINED_VARIABLE,
details: { variableName: normalized.identifier },
severity: ErrorSeverity.Recoverable
}
);
}
return undefined;
}
// Process fields/indices if present
if (normalized.fields.length > 0) {
try {
// Build field path for error reporting
const fieldPath = normalized.fields.map(f => String((f as unknown as Field).value)).join('.');
// Process each field
for (const field of normalized.fields) {
if (value === undefined) break;
const typedField = field as unknown as Field;
const fieldKey = typedField.value;
// Check if value is accessible
if (value === null || value === undefined) {
throw new Error(`Cannot access ${fieldKey} of ${value}`);
}
// Special handling for arrays with numeric indices
if (Array.isArray(value) && typeof fieldKey === 'string' && /^\d+$/.test(fieldKey)) {
const numericIndex = parseInt(fieldKey, 10);
if (numericIndex < 0 || numericIndex >= value.length) {
throw new Error(`Array index out of bounds: ${numericIndex} (length: ${value.length})`);
}
value = value[numericIndex];
}
// Object property access
else if (typeof value === 'object') {
if (!(fieldKey in value)) {
throw new Error(`Property ${fieldKey} not found in object`);
}
value = value[fieldKey];
}
// Primitive value access (will fail)
else {
throw new Error(`Cannot access field ${fieldKey} of ${typeof value}`);
}
}
} catch (error: any) {
// Create a readable field path for the error message
const fieldPathStr = normalized.fields.map(f => String((f as unknown as Field).value)).join('.');
throw new MeldResolutionError(
`Invalid field access for variable ${normalized.identifier}: ${error?.message || 'Unknown error'}`,
{
code: ResolutionErrorCode.FIELD_ACCESS_ERROR,
details: {
variableName: normalized.identifier,
fieldPath: fieldPathStr,
error: error?.message || 'Unknown error'
},
severity: ErrorSeverity.Recoverable
}
);
}
}
return value;
}
/**
* Normalizes a variable node to a common format regardless of node type
*/
private normalizeVarNode(node: MeldNode): {
identifier: string;
varType: 'text' | 'data';
fields: Array<{ type: 'field' | 'index', value: string | number }>;
} | null {
if (!node) return null;
if (node.type === 'TextVar') {
const textVarNode = node as TextVarNode;
return {
identifier: textVarNode.identifier,
varType: 'text',
fields: []
};
}
if (node.type === 'DataVar') {
const dataVarNode = node as DataVarNode;
return {
identifier: dataVarNode.identifier,
varType: 'data',
fields: dataVarNode.fields?.map(field => {
if (typeof field === 'object' && field !== null) {
const typedField = field as any; // Cast to any to bypass type checking
if (typedField.type === 'index') {
return {
type: 'index' as const,
value: typeof typedField.value === 'number' ? typedField.value : parseInt(String(typedField.value), 10)
};
} else if (typedField.type === 'field') {
return {
type: 'field' as const,
value: typedField.value
};
}
}
// Default case for unexpected field format
return {
type: 'field' as const,
value: String(field)
};
}) || []
};
}
return null;
}
/**
* Handles the resolution of standard text variables using a simpler approach
* @param text Text containing variable references
* @param context Resolution context
* @returns Text with variables resolved
*/
private resolveSimpleVariables(text: string, context: ResolutionContext): string {
// Choose state service - prefer context.state if available
const stateToUse = context.state || this.stateService;
// If no ParserService available, throw an error
if (!this.parserService) {
throw new MeldResolutionError(
'ParserService is required for variable resolution',
{
code: ResolutionErrorCode.RESOLUTION_FAILED,
severity: ErrorSeverity.Fatal,
details: { value: 'ParserService not available' }
}
);
}
try {
// Since this method is synchronous, we use a simpler regex-based approach until refactored
const textVars = this.getSafeTextVars(context);
const dataVars = this.getSafeDataVars(context);
// Simple variable replacement without using AST
let result = text;
// Skip if no variable references found
if (!text.includes('{{')) {
return text;
}
// Replace variable references in format {{varName}}
const variableRegex = /\{\{([^{}]+?)\}\}/g;
let match;
while ((match = variableRegex.exec(text)) !== null) {
const fullMatch = match[0];
const varRef = match[1];
// Handle field access in variable names (e.g., "data.user.name")
const parts = varRef.split('.');
const baseVar = parts[0];
let value: any;
// First, try to find the variable in the text variables
value = stateToUse?.getTextVar?.(baseVar);
// If not found in text variables, try data variables
if (value === undefined) {
value = stateToUse?.getDataVar?.(baseVar);
}
// If variable is not found, throw an error
if (value === undefined) {
if (baseVar.startsWith('ENV_')) {
throw new MeldResolutionError(
`Environment variable not set: ${baseVar}`,
{
code: ResolutionErrorCode.UNDEFINED_VARIABLE,
severity: ErrorSeverity.Recoverable,
details: {
variableName: baseVar,
variableType: 'text'
}
}
);
} else {
throw new MeldResolutionError(
`Undefined variable: ${baseVar}`,
{
code: ResolutionErrorCode.UNDEFINED_VARIABLE,
severity: ErrorSeverity.Recoverable,
details: {
variableName: baseVar,
variableType: parts.length > 1 ? 'data' as const : 'text' as const
}
}
);
}
}
// For data variables with field access, resolve fields
if (parts.length > 1 && typeof value === 'object' && value !== null) {
try {
// Store the original object for comparison
const originalObject = value;
// Direct implementation of field access
let current = value;
// Wrap any console.log statements in DEBUG checks
if (process.env.DEBUG === 'true') {
console.log('FIELD ACCESS - Initial object:', JSON.stringify(value, null, 2));
console.log('FIELD ACCESS - Field path:', parts.slice(1));
}
// Process each field in the path
for (const field of parts.slice(1)) {
if (process.env.DEBUG === 'true') {
console.log(`FIELD ACCESS - Accessing field: ${field}`);
}
// Check if we can access this field
if (current === null || current === undefined) {
if (process.env.DEBUG === 'true') {
console.log('FIELD ACCESS - Cannot access field of null/undefined');
}
throw new Error(`Cannot access field ${field} of undefined or null`);
}
// Check if the current value is an object and has the field
if (typeof current !== 'object' || !(field in current)) {
if (process.env.DEBUG === 'true') {
console.log(`FIELD ACCESS - Field ${field} not found in object:`, current);
}
throw new Error(`Cannot access field ${field} of ${typeof current}`);
}
// Access the field - improve handling of array indices
if (Array.isArray(current) && /^\d+$/.test(field)) {
const index = parseInt(field, 10);
if (index < 0 || index >= current.length) {
if (process.env.DEBUG === 'true') {
console.log(`FIELD ACCESS - Array index out of bounds: ${index} (length: ${current.length})`);
}
throw new Error(`Array index out of bounds: ${index} (length: ${current.length})`);
}
current = current[index];
} else {
current = current[field];
}
if (process.env.DEBUG === 'true') {
console.log(`FIELD ACCESS - Field value:`, current);
}
}
// Update the value with the field access result
value = current;
// Check if the field access actually changed the value
if (value === originalObject) {
if (process.env.DEBUG === 'true') {
console.warn(`Field access may not have worked correctly for ${parts.join('.')}`);
}
}
if (process.env.DEBUG === 'true') {
console.log('FIELD ACCESS - Final result:', value);
}
} catch (error) {
if (error instanceof MeldResolutionError) {
throw error;
}
throw new MeldResolutionError(
`Failed to access field ${parts.slice(1).join('.')} in ${baseVar}`,
{
code: ResolutionErrorCode.FIELD_ACCESS_ERROR,
severity: ErrorSeverity.Recoverable,
details: {
variableName: baseVar,
value: `Error accessing ${parts.slice(1).join('.')}: ${(error as Error).message}`
}
}
);
}
}
// Stringification logic - key part of the fix
let stringValue: string;
if (typeof value === 'object' && value !== null) {
if (parts.length === 1) {
// We're not doing field access, stringify the whole object
// Use pretty-printed JSON for better readability
stringValue = JSON.stringify(value, null, 2);
} else {
// We were doing field access - only stringify if the result is still an object
stringValue = typeof value === 'object' ? JSON.stringify(value, null, 2) : String(value);
}
} else {
// For primitive values, just convert to string
stringValue = String(value);
}
// Convert any undefined values to empty strings
if (value === undefined) {
stringValue = '';
}
// Replace the variable in the text
result = result.replace(fullMatch, stringValue);
}
return result;
} catch (error) {
console.error('*** Error during variable resolution:', error);
throw error;
}
}
/**
* Extract variable nodes from the AST
* @param nodes AST nodes
* @returns Array of variable reference nodes
*/
private extractVariableNodesFromAst(nodes: MeldNode[]): MeldNode[] {
if (!nodes) {
return [];
}
const variableNodes: MeldNode[] = [];
// Process each node
for (const node of nodes) {
if (node.type === 'TextVar' || node.type === 'DataVar' ||
(node.type === 'Directive' &&
((node as any).directive?.kind === 'text' || (node as any).directive?.kind === 'data'))) {
variableNodes.push(node);
} else if (node.type === 'Text') {
// For text nodes, check if they contain variable references
const textContent = (node as TextNode).content;
if (textContent.includes('{{')) {
// Parse the text content to extract variable references
try {
const subNodes = this.parserService?.parse(textContent);
if (subNodes && Array.isArray(subNodes)) {
const subVarNodes = this.extractVariableNodesFromAst(subNodes);
variableNodes.push(...subVarNodes);
}
} catch (error) {
// If parsing fails, just continue
console.log('*** Failed to parse variable references from text node:', textContent);
}
}
}
}
return variableNodes;
}
/**
* Extract references using AST - now properly handles async
*/
private async extractReferencesAst(text: string): Promise<string[]> {
try {
// Check if parser service is available
if (!this.parserService) {
throw new Error('ParserService is required for variable resolution');
}
// Use the parser to get AST nodes
const nodes = await this.parserService.parse(text);
// Extract variable names from nodes
if (!nodes) {
return [];
}
return this.extractReferencesFromNodes(nodes);
} catch (error) {
console.error('*** Error during AST-based variable extraction:', error);
// Don't fall back to regex - just return empty array
return [];
}
}
/**
* Extract references from AST nodes
*/
private extractReferencesFromNodes(nodes: MeldNode[]): string[] {
if (!nodes) {
return [];
}
const references = new Set<string>();
for (const node of nodes) {
if (node.type === 'Text') {
// Extract from text content
const textNode = node as TextNode;
const extracted = this.extractReferencesFromText(textNode.content);
if (Array.isArray(extracted)) {
extracted.forEach(ref => references.add(ref));
}
} else if (node.type === 'TextVar' || node.type === 'DataVar' || node.type === 'PathVar' ||
(node.type === 'Directive' &&
((node as any).directive?.kind === 'text' || (node as any).directive?.kind === 'data'))) {
// Extract from variable nodes
const varNode = node as any;
const varName = varNode.identifier || varNode.variable ||
(varNode.directive?.identifier) ||
(varNode.directive?.variable);
if (varName) {
references.add(varName);
}
}
}
return Array.from(references);
}
/**
* Extract references from text content (helper method)
*/
private extractReferencesFromText(text: string): string[] {
// Use regex pattern matching instead of trying to call async method
const references = new Set<string>();
// Match {{varName}} pattern without using the parser
const matches = text.match(/\{\{([^{}]+)\}\}/g) || [];
for (const match of matches) {
// Extract variable name without braces
const varName = match.substring(2, match.length - 2);
// For field access (data.field.subfield), only include the base variable
const baseVar = varName.split('.')[0];
references.add(baseVar);
}
return Array.from(references);
}
/**
* Extract references using regex pattern matching - now delegates to async method
* This is kept for backward compatibility
*/
private extractReferencesRegex(text: string): string[] {
// We can't directly use the async method in a sync context,
// so we'll implement a simpler version
const references = new Set<string>();
// Match {{varName}} pattern without using the parser
const matches = text.match(/\{\{([^{}]+)\}\}/g) || [];
for (const match of matches) {
// Extract variable name without braces
const varName = match.substring(2, match.length - 2);
// For field access (data.field.subfield), only include the base variable
const baseVar = varName.split('.')[0];
references.add(baseVar);
}
return Array.from(references);
}
// Safe accessor methods to handle different context shapes
private getSafeTextVars(context: ResolutionContext): Record<string, string> {
if (!context || !context.state) {
return {};
}
try {
// Convert Map to plain object if needed
const textVars = context.state.getAllTextVars();
if (textVars instanceof Map) {
return Object.fromEntries(textVars);
}
return textVars || {};
} catch (error) {
console.error('Error accessing text variables:', error);
return {};
}
}
private getSafeDataVars(context: ResolutionContext): Record<string, any> {
if (!context || !context.state) {
return {};
}
try {
// Get data variables from state
const dataVars = context.state.getAllDataVars();
// Convert to plain object if needed
if (dataVars instanceof Map) {
return Object.fromEntries(dataVars);
}
return dataVars || {};
} catch (error) {
console.error('Error accessing data variables:', error);
return {};
}
}
private async resolveWithAst(text: string, context: ResolutionContext): Promise<string> {
// Check if parser service is available
if (!this.parserService) {
throw new Error('Parser service not available');
}
// Parse the text to get AST nodes
const nodes = await this.parserService.parse(text);
console.log('*** Parser result:', {
hasNodes: !!nodes,
nodeCount: nodes?.length || 0,
nodeTypes: nodes?.map(n => n.type) || []
});
// If parsing failed or returned empty, return original text
if (!nodes || nodes.length === 0) {
console.log('*** No AST nodes, falling back to simple variables');
return this.resolveSimpleVariables(text, context);
}
// Process nodes to resolve variables
console.log('*** Processing AST nodes');
const result = await this.resolveNodes(nodes, context);
console.log('*** AST processing result:', result);
return result;
}
/**
* Check if text contains variable references
*/
private async hasVariableReferences(text: string): Promise<boolean> {
try {
// Parse the text to find variable nodes
const nodes = await this.parserService?.parse(text);
const hasVariables = nodes?.some(node =>
node.type === 'TextVar' ||
node.type === 'DataVar' ||
node.type === 'PathVar' ||
(node.type === 'Directive' &&
((node as any).directive?.kind === 'text' ||
(node as any).directive?.kind === 'data' ||
(node as any).directive?.kind === 'command'))
);
return hasVariables || false;
} catch (error) {
// If parsing fails, fall back to simple check
return text.includes('{{');
}
}
/**
* Extract variable references from a string - now properly uses parser when available
* @param text The text to search for references
* @returns Array of unique variable names
*/
extractReferences(text: string): string[] {
// Use regex-based extraction for sync method
// This method should later be refactored to use the async version
return this.extractReferencesRegex(text);
}
/**
* Extract variable references from text (async version)
* Note: This is needed for proper async handling with the parser.
* @param text Text to extract references from
* @returns Promise resolving to array of variable names
*/
async extractReferencesAsync(text: string): Promise<string[]> {
try {
// Check if parser service is available
if (!this.parserService) {
return this.extractReferencesRegex(text);
}
// Parse the text into nodes
const nodes = await this.parserService.parse(text);
if (!nodes) {
return [];
}
// Extract references from the nodes
return this.extractReferencesFromNodes(nodes);
} catch (error) {
console.error('*** Error during variable reference extraction:', error);
// Fall back to regex extraction
return this.extractReferencesRegex(text);
}
}
/**
* Debug helper to trace field access resolution
* @param obj The object to access fields on
* @param fields Array of field names to access
* @param context Resolution context
* @returns Detailed debug information about field access
*/
private debugFieldAccess(obj: any, fields: string[], context: ResolutionContext): {
result: any;
steps: Array<{ field: string; type: string; value: any; }>;
} {
if (!obj) {
return {
result: undefined,
steps: [{ field: 'initial', type: typeof obj, value: obj }]
};
}
let current = obj;
const steps: Array<{ field: string; type: string; value: any; }> = [
{ field: 'initial', type: Array.isArray(obj) ? 'array' : typeof obj, value: obj }
];
for (const field of fields) {
// For arrays, check if the field is a valid numeric index
if (Array.isArray(current) && /^\d+$/.test(field)) {
const index = parseInt(field, 10);
if (index < 0 || index >= current.length) {
steps.push({ field, type: 'error', value: `Array index out of bounds: ${index} (array length: ${current.length})` });
return { result: undefined, steps };
}
current = current[index];
steps.push({ field, type: Array.isArray(current) ? 'array' : typeof current, value: current });
}
// For objects, check if the field exists
else if (typeof current === 'object' && current !== null) {
if (!(field in current)) {
steps.push({ field, type: 'error', value: `Field not found on object: ${field}` });
return { result: undefined, steps };
}
current = current[field];
steps.push({ field, type: Array.isArray(current) ? 'array' : typeof current, value: current });
}
// Handle primitive values
else {
steps.push({ field, type: 'error', value: `Cannot access field ${field} on primitive value: ${current}` });
return { result: undefined, steps };
}
}
return { result: current, steps };
}
/**
* Gets a variable from the state service
* @param name The variable name
* @param context The resolution context
* @returns The variable value
*/
private async getVariable(name: string, context: ResolutionContext): Promise<any> {
const { state, allowedVariableTypes = { text: true, data: true } } = context;
if (!state) {
// Track failed resolution attempt
this.trackResolutionAttempt(name, context, false, undefined, 'State service not available');
throw new Error('State service not available');
}
// Try to get a text variable first
let value: any;
if (allowedVariableTypes.text) {
value = state.getTextVar(name);
if (value !== undefined) {
// Track successful text variable resolution
this.trackResolutionAttempt(name, context, true, value, 'text');
return value;
}
}
// If no text variable found, try data variable
if (value === undefined && allowedVariableTypes.data) {
value = state.getDataVar(name);
if (value !== undefined) {
// Track successful data variable resolution
this.trackResolutionAttempt(name, context, true, value, 'data');
return value;
}
}
// Handle environment variables if relevant
// Note: We check if 'env' property exists before using it
if (value === undefined && allowedVariableTypes && 'env' in allowedVariableTypes && allowedVariableTypes.env) {
value = process.env[name];
if (value !== undefined) {
// Track successful environment variable resolution
this.trackResolutionAttempt(name, context, true, value, 'env');
return value;
}
}
// Track failed resolution attempt
this.trackResolutionAttempt(name, context, false, undefined, 'Variable not found');
return value;
}
/**
* Track a variable resolution attempt if tracker is available
* @private
*/
private trackResolutionAttempt(
variableName: string,
context: ResolutionContext,
success: boolean,
value?: any,
source?: string
): void {
if (!this.resolutionTracker) return;
this.resolutionTracker.trackResolutionAttempt(
variableName,
context.currentFilePath || 'unknown',
success,
value,
source
);
}
async resolveFieldAccess(variableName: string, fieldPath: string, context: ResolutionContext): Promise<any> {
// Get the base variable value
const value = await this.getVariable(variableName, context);
if (value === undefined) {
throw new MeldResolutionError(
`Variable ${variableName} not found`,
{
code: ResolutionErrorCode.RESOLUTION_FAILED,
details: { variableName, fieldPath }
}
);
}
// If no field path, return the value directly
if (!fieldPath) {
return value;
}
// Split the field path into segments
const fieldSegments = fieldPath.split('.');
// Traverse the object/array structure
let currentValue = value;
let currentPath = '';
for (const segment of fieldSegments) {
if (currentValue === undefined || currentValue === null) {
throw new MeldResolutionError(
`Cannot access field ${fieldPath} in ${variableName}: path ${currentPath} is ${currentValue}`,
{
code: ResolutionErrorCode.RESOLUTION_FAILED,
details: {
variableName,
fieldPath,
value: `Error accessing ${fieldPath}: path ${currentPath} is ${currentValue}`
}
}
);
}
currentPath = currentPath ? `${currentPath}.${segment}` : segment;
// Check if segment is a numeric index and current value is an array
if (Array.isArray(currentValue) && /^\d+$/.test(segment)) {
const index = parseInt(segment, 10);
if (index < 0 || index >= currentValue.length) {
throw new MeldResolutionError(
`Array index out of bounds: ${index} (length: ${currentValue.length})`,
{
code: ResolutionErrorCode.RESOLUTION_FAILED,
details: {
variableName,
fieldPath,
value: `Error accessing ${fieldPath}: index ${index} out of bounds`
}
}
);
}
currentValue = currentValue[index];
}
// Handle object property access
else if (typeof currentValue === 'object' && currentValue !== null) {
// First try to access as a property
if (segment in currentValue) {
currentValue = currentValue[segment];
}
// If that fails and segment is numeric, try array access
// This is a fallback for objects with numeric keys stored as numbers
else if (/^\d+$/.test(segment) && Array.isArray(currentValue)) {
const index = parseInt(segment, 10);
if (index < 0 || index >= currentValue.length) {
throw new MeldResolutionError(
`Array index out of bounds: ${index} (length: ${currentValue.length})`,
{
code: ResolutionErrorCode.RESOLUTION_FAILED,
details: {
variableName,
fieldPath,
value: `Error accessing ${fieldPath}: index ${index} out of bounds`
}
}
);
}
currentValue = currentValue[index];
} else {
throw new MeldResolutionError(
`Property ${segment} not found in object at path ${currentPath}`,
{
code: ResolutionErrorCode.RESOLUTION_FAILED,
details: {
variableName,
fieldPath,
value: `Error accessing ${fieldPath}: property ${segment} not found`
}
}
);
}
}
// Handle direct value access (e.g., string[0])
else if (typeof currentValue === 'string' && /^\d+$/.test(segment)) {
const index = parseInt(segment, 10);
if (index < 0 || index >= currentValue.length) {
throw new MeldResolutionError(
`String index out of bounds: ${index} (length: ${currentValue.length})`,
{
code: ResolutionErrorCode.RESOLUTION_FAILED,
details: {
variableName,
fieldPath,
value: `Error accessing ${fieldPath}: string index ${index} out of bounds`
}
}
);
}
currentValue = currentValue[index];
}
else {
throw new MeldResolutionError(
`Cannot access field ${segment} on non-object value at path ${currentPath}`,
{
code: ResolutionErrorCode.RESOLUTION_FAILED,
details: {
variableName,
fieldPath,
value: `Error accessing ${fieldPath}: cannot access field ${segment} on value of type ${typeof currentValue}`
}
}
);
}
}
return currentValue;
}
}