meld
Version:
Meld: A template language for LLM prompts
620 lines (533 loc) • 19.8 kB
text/typescript
import type { MeldNode, TextNode } from 'meld-spec';
import { stateLogger as logger } from '@core/utils/logger.js';
import type { IStateService, TransformationOptions } from './IStateService.js';
import type { StateNode, CommandDefinition } from './types.js';
import { StateFactory } from './StateFactory.js';
import type { IStateEventService, StateEvent } from '../StateEventService/IStateEventService.js';
import type { IStateTrackingService } from '@tests/utils/debug/StateTrackingService/IStateTrackingService.js';
export class StateService implements IStateService {
private stateFactory: StateFactory;
private currentState: StateNode;
private _isImmutable: boolean = false;
private _transformationEnabled: boolean = false;
private _transformationOptions: TransformationOptions = {
variables: false,
directives: false,
commands: false,
imports: false
};
private eventService?: IStateEventService;
private trackingService?: IStateTrackingService;
constructor(parentState?: IStateService) {
this.stateFactory = new StateFactory();
this.currentState = this.stateFactory.createState({
source: 'new',
parentState: parentState ? (parentState as StateService).currentState : undefined
});
// If parent has services, inherit them
if (parentState) {
const parent = parentState as StateService;
if (parent.eventService) {
this.eventService = parent.eventService;
}
if (parent.trackingService) {
this.trackingService = parent.trackingService;
}
}
// Register state with tracking service if available
if (this.trackingService) {
const parentId = parentState ? (parentState as StateService).currentState.stateId : undefined;
// Register the state with the pre-generated ID
this.trackingService.registerState({
id: this.currentState.stateId,
source: 'new',
parentId,
filePath: this.currentState.filePath,
transformationEnabled: this._transformationEnabled
});
// Add parent-child relationship if there is a parent
if (parentId) {
this.trackingService.addRelationship(
parentId,
this.currentState.stateId!,
'parent-child'
);
}
}
}
setEventService(eventService: IStateEventService): void {
this.eventService = eventService;
}
private async emitEvent(event: StateEvent): Promise<void> {
if (this.eventService) {
await this.eventService.emit(event);
}
}
// Text variables
getTextVar(name: string): string | undefined {
return this.currentState.variables.text.get(name);
}
setTextVar(name: string, value: string): void {
this.checkMutable();
const text = new Map(this.currentState.variables.text);
text.set(name, value);
this.updateState({
variables: {
...this.currentState.variables,
text
}
}, `setTextVar:${name}`);
}
getAllTextVars(): Map<string, string> {
return new Map(this.currentState.variables.text);
}
getLocalTextVars(): Map<string, string> {
return new Map(this.currentState.variables.text);
}
// Data variables
getDataVar(name: string): unknown {
return this.currentState.variables.data.get(name);
}
setDataVar(name: string, value: unknown): void {
this.checkMutable();
const data = new Map(this.currentState.variables.data);
data.set(name, value);
this.updateState({
variables: {
...this.currentState.variables,
data
}
}, `setDataVar:${name}`);
}
getAllDataVars(): Map<string, unknown> {
return new Map(this.currentState.variables.data);
}
getLocalDataVars(): Map<string, unknown> {
return new Map(this.currentState.variables.data);
}
// Path variables
getPathVar(name: string): string | undefined {
return this.currentState.variables.path.get(name);
}
setPathVar(name: string, value: string): void {
this.checkMutable();
const path = new Map(this.currentState.variables.path);
path.set(name, value);
this.updateState({
variables: {
...this.currentState.variables,
path
}
}, `setPathVar:${name}`);
}
getAllPathVars(): Map<string, string> {
return new Map(this.currentState.variables.path);
}
// Commands
getCommand(name: string): CommandDefinition | undefined {
return this.currentState.commands.get(name);
}
setCommand(name: string, command: string | CommandDefinition): void {
this.checkMutable();
const commands = new Map(this.currentState.commands);
const commandDef = typeof command === 'string' ? { command } : command;
commands.set(name, commandDef);
this.updateState({ commands }, `setCommand:${name}`);
}
getAllCommands(): Map<string, CommandDefinition> {
return new Map(this.currentState.commands);
}
// Nodes
getNodes(): MeldNode[] {
return [...this.currentState.nodes];
}
getTransformedNodes(): MeldNode[] {
if (this._transformationEnabled) {
return this.currentState.transformedNodes ? [...this.currentState.transformedNodes] : [...this.currentState.nodes];
}
return [...this.currentState.nodes];
}
setTransformedNodes(nodes: MeldNode[]): void {
this.checkMutable();
this.updateState({ transformedNodes: nodes }, 'setTransformedNodes');
}
addNode(node: MeldNode): void {
this.checkMutable();
const nodes = [...this.currentState.nodes, node];
const transformedNodes = this._transformationEnabled ?
(this.currentState.transformedNodes ? [...this.currentState.transformedNodes, node] : [...nodes]) :
undefined;
this.updateState({ nodes, transformedNodes }, 'addNode');
}
transformNode(original: MeldNode, transformed: MeldNode): void {
this.checkMutable();
if (!this._transformationEnabled) {
return;
}
// Initialize transformed nodes if needed
let transformedNodes = this.currentState.transformedNodes ?
[...this.currentState.transformedNodes] :
[...this.currentState.nodes];
// First try direct reference comparison
let index = transformedNodes.findIndex(node => node === original);
// If not found by reference, try matching by location
if (index === -1 && original.location && transformed.location) {
index = transformedNodes.findIndex(node =>
node.location?.start?.line === original.location?.start?.line &&
node.location?.start?.column === original.location?.start?.column &&
node.location?.end?.line === original.location?.end?.line &&
node.location?.end?.column === original.location?.end?.column
);
}
if (index !== -1) {
// Replace the node at the found index
transformedNodes[index] = transformed;
} else {
// If not found in transformed nodes, check original nodes
const originalIndex = this.currentState.nodes.findIndex(node => {
if (!node.location || !original.location) return false;
return (
node.location.start.line === original.location.start.line &&
node.location.start.column === original.location.start.column &&
node.location.end.line === original.location.end.line &&
node.location.end.column === original.location.end.column
);
});
if (originalIndex === -1) {
throw new Error('Cannot transform node: original node not found');
}
// Replace the node at the original index
transformedNodes[originalIndex] = transformed;
}
this.updateState({ transformedNodes }, 'transformNode');
}
isTransformationEnabled(): boolean {
return this._transformationEnabled;
}
/**
* Check if a specific transformation type is enabled
* @param type The transformation type to check (variables, directives, commands, imports)
* @returns Whether the specified transformation type is enabled
*/
shouldTransform(type: keyof TransformationOptions): boolean {
return this._transformationEnabled && Boolean(this._transformationOptions[type]);
}
/**
* Enable transformation with specific options
* @param options Options for selective transformation, or true/false for all
*/
enableTransformation(options?: TransformationOptions | boolean): void {
if (typeof options === 'boolean') {
// Legacy behavior - all on or all off
this._transformationEnabled = options;
this._transformationOptions = options ?
{ variables: true, directives: true, commands: true, imports: true } :
{ variables: false, directives: false, commands: false, imports: false };
} else {
// Selective transformation
this._transformationEnabled = true;
this._transformationOptions = {
...{ variables: true, directives: true, commands: true, imports: true },
...options
};
}
if (this._transformationEnabled && !this.currentState.transformedNodes) {
// Initialize transformed nodes with current nodes when enabling transformation
this.updateState({ transformedNodes: [...this.currentState.nodes] }, 'enableTransformation');
}
}
/**
* Get the current transformation options
* @returns The current transformation options
*/
getTransformationOptions(): TransformationOptions {
return { ...this._transformationOptions };
}
appendContent(content: string): void {
this.checkMutable();
// Create a text node and add it
const textNode: TextNode = {
type: 'Text',
content,
location: { start: { line: 0, column: 0 }, end: { line: 0, column: 0 } }
};
this.addNode(textNode);
}
// Imports
addImport(path: string): void {
this.checkMutable();
const imports = new Set(this.currentState.imports);
imports.add(path);
this.updateState({ imports }, `addImport:${path}`);
}
removeImport(path: string): void {
this.checkMutable();
const imports = new Set(this.currentState.imports);
imports.delete(path);
this.updateState({ imports }, `removeImport:${path}`);
}
hasImport(path: string): boolean {
return this.currentState.imports.has(path);
}
getImports(): Set<string> {
return new Set(this.currentState.imports);
}
// File path
getCurrentFilePath(): string | null {
return this.currentState.filePath ?? null;
}
setCurrentFilePath(path: string): void {
this.checkMutable();
this.updateState({ filePath: path }, 'setCurrentFilePath');
}
// State management
/**
* In the immutable state model, any non-empty state is considered to have local changes.
* This is a deliberate design choice - each state represents a complete snapshot,
* so the entire state is considered "changed" from its creation.
*
* @returns Always returns true to indicate the state has changes
*/
hasLocalChanges(): boolean {
return true; // In immutable model, any non-empty state has local changes
}
/**
* Returns a list of changed elements in the state. In the immutable state model,
* the entire state is considered changed from creation, so this always returns
* ['state'] to indicate the complete state has changed.
*
* This is a deliberate design choice that aligns with the immutable state model
* where each state is a complete snapshot.
*
* @returns Always returns ['state'] to indicate the entire state has changed
*/
getLocalChanges(): string[] {
return ['state']; // In immutable model, the entire state is considered changed
}
setImmutable(): void {
this._isImmutable = true;
}
get isImmutable(): boolean {
return this._isImmutable;
}
createChildState(): IStateService {
const child = new StateService(this);
// Copy transformation state
child._transformationEnabled = this._transformationEnabled;
if (child._transformationEnabled && !child.currentState.transformedNodes) {
child.currentState = this.stateFactory.updateState(child.currentState, {
transformedNodes: [...child.currentState.nodes]
});
}
logger.debug('Created child state', {
parentPath: this.getCurrentFilePath(),
childPath: child.getCurrentFilePath()
});
// Emit create event
this.emitEvent({
type: 'create',
stateId: child.currentState.filePath || 'unknown',
source: 'createChildState',
timestamp: Date.now(),
location: {
file: this.getCurrentFilePath() || undefined
}
});
return child;
}
mergeChildState(childState: IStateService): void {
this.checkMutable();
const child = childState as StateService;
this.currentState = this.stateFactory.mergeStates(this.currentState, child.currentState);
// Add merge relationship if tracking enabled
if (this.trackingService && child.currentState.stateId) {
// Add merge-source relationship without removing the existing parent-child relationship
this.trackingService.addRelationship(
this.currentState.stateId!,
child.currentState.stateId,
'merge-source'
);
}
// Emit merge event
this.emitEvent({
type: 'merge',
stateId: this.currentState.stateId || 'unknown',
source: 'mergeChildState',
timestamp: Date.now(),
location: {
file: this.getCurrentFilePath() || undefined
}
});
}
clone(): IStateService {
const cloned = new StateService();
// Create a completely new state without parent reference
cloned.currentState = this.stateFactory.createState({
source: 'clone',
filePath: this.currentState.filePath
});
// Deep clone all state using our helper
cloned.updateState({
variables: {
text: this.deepCloneValue(this.currentState.variables.text),
data: this.deepCloneValue(this.currentState.variables.data),
path: this.deepCloneValue(this.currentState.variables.path)
},
commands: this.deepCloneValue(this.currentState.commands),
nodes: this.deepCloneValue(this.currentState.nodes),
transformedNodes: this.currentState.transformedNodes ?
this.deepCloneValue(this.currentState.transformedNodes) : undefined,
imports: this.deepCloneValue(this.currentState.imports)
}, 'clone');
// Copy flags
cloned._isImmutable = this._isImmutable;
cloned._transformationEnabled = this._transformationEnabled;
// Initialize transformation state if enabled
if (cloned._transformationEnabled && !cloned.currentState.transformedNodes) {
cloned.currentState = this.stateFactory.updateState(cloned.currentState, {
transformedNodes: [...cloned.currentState.nodes]
});
}
// Copy service references
if (this.eventService) {
cloned.setEventService(this.eventService);
}
if (this.trackingService) {
cloned.setTrackingService(this.trackingService);
// Register the cloned state with tracking service
this.trackingService.registerState({
id: cloned.currentState.stateId!,
source: 'clone',
parentId: this.currentState.stateId,
filePath: cloned.currentState.filePath,
transformationEnabled: cloned._transformationEnabled
});
// Add clone relationship as parent-child since 'clone' is not a valid relationship type
this.trackingService.addRelationship(
this.currentState.stateId!,
cloned.currentState.stateId!,
'parent-child' // Changed from 'clone' to 'parent-child'
);
}
// Emit clone event
this.emitEvent({
type: 'clone',
stateId: cloned.currentState.stateId || 'unknown',
source: 'clone',
timestamp: Date.now(),
location: {
file: this.getCurrentFilePath() || undefined
}
});
return cloned;
}
private checkMutable(): void {
if (this._isImmutable) {
throw new Error('Cannot modify immutable state');
}
}
/**
* Deep clones a value, handling objects, arrays, Maps, Sets, and circular references.
* @param value The value to clone
* @param seen A WeakMap to track circular references
* @returns A deep clone of the value
*/
private deepCloneValue<T>(value: T, seen: WeakMap<any, any> = new WeakMap()): T {
// Handle null, undefined, and primitive types
if (value === null || value === undefined || typeof value !== 'object') {
return value;
}
// Handle circular references
if (seen.has(value)) {
return seen.get(value);
}
// Handle Date objects
if (value instanceof Date) {
return new Date(value.getTime()) as unknown as T;
}
// Handle Arrays
if (Array.isArray(value)) {
const clone = [] as unknown as T;
seen.set(value, clone);
(value as unknown as any[]).forEach((item, index) => {
(clone as unknown as any[])[index] = this.deepCloneValue(item, seen);
});
return clone;
}
// Handle Maps
if (value instanceof Map) {
const clone = new Map() as unknown as T;
seen.set(value, clone);
(value as Map<any, any>).forEach((val, key) => {
(clone as unknown as Map<any, any>).set(
this.deepCloneValue(key, seen),
this.deepCloneValue(val, seen)
);
});
return clone;
}
// Handle Sets
if (value instanceof Set) {
const clone = new Set() as unknown as T;
seen.set(value, clone);
(value as Set<any>).forEach(item => {
(clone as unknown as Set<any>).add(this.deepCloneValue(item, seen));
});
return clone;
}
// Handle plain objects (including MeldNodes and CommandDefinitions)
const clone = Object.create(Object.getPrototypeOf(value));
seen.set(value, clone);
Object.entries(value as object).forEach(([key, val]) => {
clone[key] = this.deepCloneValue(val, seen);
});
return clone;
}
private updateState(updates: Partial<StateNode>, source: string): void {
this.currentState = this.stateFactory.updateState(this.currentState, updates);
// Emit transform event for state updates
this.emitEvent({
type: 'transform',
stateId: this.currentState.stateId || 'unknown',
source,
timestamp: Date.now(),
location: {
file: this.getCurrentFilePath() || undefined
}
});
}
// Add new methods for state tracking
setTrackingService(trackingService: IStateTrackingService): void {
this.trackingService = trackingService;
// Register existing state if not already registered
if (this.currentState.stateId) {
try {
this.trackingService.registerState({
id: this.currentState.stateId,
source: this.currentState.source || 'new', // Use original source or default to 'new'
filePath: this.getCurrentFilePath() || undefined,
transformationEnabled: this._transformationEnabled
});
} catch (error) {
logger.warn('Failed to register existing state with tracking service', { error, stateId: this.currentState.stateId });
}
}
}
getStateId(): string | undefined {
return this.currentState.stateId;
}
getCommandOutput(command: string): string | undefined {
if (!this._transformationEnabled || !this.currentState.transformedNodes) {
return undefined;
}
// Find the transformed node that matches this command
const transformedNode = this.currentState.transformedNodes.find(node => {
if (node.type !== 'Text') return false;
return (node as TextNode).content === command;
});
return transformedNode?.type === 'Text' ? (transformedNode as TextNode).content : undefined;
}
hasTransformationSupport(): boolean {
return true;
}
}