diff --git a/flowming/src/components/Flow/FlowContent.tsx b/flowming/src/components/Flow/FlowContent.tsx index c3ecec2..eacf4f9 100644 --- a/flowming/src/components/Flow/FlowContent.tsx +++ b/flowming/src/components/Flow/FlowContent.tsx @@ -25,7 +25,7 @@ import { NodeBlock } from '../Toolbar/ToolbarTypes'; import ContextMenu from './ContextMenu'; import { useDnD } from '../../context/DnDContext'; import { useFlowExecutorState } from '../../context/FlowExecutorContext'; -import { Expression, Variable } from '../../models'; +import { Expression, Variable, ExpressionElement } from '../../models'; import { decisionEdgeLabels } from './Nodes/Conditional'; import FilenameEditor from '../FilenameEditor'; import { useCollaboration } from '../../context/CollaborationContext'; @@ -659,6 +659,40 @@ const FlowContent: React.FC = () => { if (!needsUpdate) return; + // Helper to recursively update ExpressionElement lists (array index expressions) + const updateElements = (elements: any[]): ExpressionElement[] => { + return elements.flatMap((rawElem: any) => { + // Ensure we are working with an ExpressionElement instance (objects coming from Yjs/serialization won't have class methods) + const elem: ExpressionElement = rawElem instanceof ExpressionElement + ? rawElem + : ExpressionElement.fromObject(rawElem); + + if (elem.type !== 'variable') { + // Propagate into nested function calls, if any + if (elem.isFunction() && elem.nestedExpression) { + elem.nestedExpression.rightSide = updateElements(elem.nestedExpression.rightSide); + } + return [elem]; + } + + const match = variables.find(v => v.id === elem.variable?.id); + if (!match) return []; // Variable deleted -> drop element + + const clonedVar = match.clone(); + if (elem.variable?.indexExpression && clonedVar.type === 'array') { + const newIdx = updateElements(elem.variable.indexExpression as ExpressionElement[]); + if (newIdx.length > 0) clonedVar.indexExpression = newIdx; + } + + // If index expression became empty, remove the whole variable element + if (elem.variable?.indexExpression && (!clonedVar.indexExpression || clonedVar.indexExpression.length === 0)) { + return []; + } + + return [new ExpressionElement(elem.id, elem.type, clonedVar.toString(), clonedVar)]; + }); + }; + setNodes(prevNodes => prevNodes.map(node => { if ((node.type === 'AssignVariable' || node.type === 'Conditional' || node.type === 'Output') && node.data.expression) { try { @@ -668,13 +702,59 @@ const FlowContent: React.FC = () => { const expression = Expression.fromObject(currentExprData); expression.updateVariables(variables); + // Cleanup for left-side Variable (especially array element assignments) + if (expression.leftSide instanceof Variable) { + const leftSideId = expression.leftSide.id; + const leftMatch = variables.find(v => v.id === leftSideId); + + if (!leftMatch) { + expression.leftSide = undefined; // Variable removed entirely + } else { + const leftClone = leftMatch.clone(); + + // Recursively update its index expression (if any) + if (expression.leftSide.indexExpression && expression.leftSide.indexExpression.length > 0) { + const updatedIdx = updateElements(expression.leftSide.indexExpression as ExpressionElement[]); + if (updatedIdx.length > 0) { + leftClone.indexExpression = updatedIdx; + } + } + + // If after update the index expression is gone, drop the left side reference + if (expression.leftSide.indexExpression && (!leftClone.indexExpression || leftClone.indexExpression.length === 0)) { + expression.leftSide = undefined; + } else { + expression.leftSide = leftClone; + } + } + } else if (Array.isArray(expression.leftSide)) { + const cleanedLeft = updateElements(expression.leftSide as ExpressionElement[]); + if (cleanedLeft.length > 0) { + expression.leftSide = cleanedLeft; + } else { + expression.leftSide = undefined; + } + } + return { ...node, data: { ...node.data, expression: expression.toObject() } }; } catch { return { ...node, data: { ...node.data, expression: null } }; } } else if (node.type === 'Input' && node.data.variable) { const updatedVariable = variables.find(v => v.id === node.data.variable?.id); - return { ...node, data: { ...node.data, variable: updatedVariable ? JSON.parse(JSON.stringify(updatedVariable)) : undefined } }; + if (updatedVariable) { + const varCopy = updatedVariable.clone(); + if (node.data.variable.indexExpression && node.data.variable.indexExpression.length > 0) { + const updatedIdx = updateElements(node.data.variable.indexExpression as ExpressionElement[]); + if (updatedIdx.length > 0) { + varCopy.indexExpression = updatedIdx; + } else { + return { ...node, data: { ...node.data, variable: undefined } }; + } + } + return { ...node, data: { ...node.data, variable: varCopy } }; + } + return { ...node, data: { ...node.data, variable: undefined } }; } return node; @@ -862,6 +942,16 @@ const FlowContent: React.FC = () => { // TODO: cannot move draggable elements in expression builder if its >= 2 lines height (only horizontally) // Timer: https://www.timeanddate.com/countdown/generic?iso=20250616T12&p0=%3A&font=cursive + + // IDEA: Declare variable block could not be a block, and a simply display in some corner of the flow + // It would be displayed as a square with a form just like the one in the editor for Declare + // In this way, adding a "irrelevant" block wouldn't be needed and you would have a global, easy-to-access + // form to view and modify declared variables across the whole program + // (and also, it would be easier to add a new variable to the program) + // Does order of DeclareVariable nodes matter in the flow currently? I don't think so + + // TODO: stricter type checking between float and integer? + // TODO: stricter display format for float (.0) > diff --git a/flowming/src/components/Flow/Nodes/AssignVariable.tsx b/flowming/src/components/Flow/Nodes/AssignVariable.tsx index d59a63d..1681184 100644 --- a/flowming/src/components/Flow/Nodes/AssignVariable.tsx +++ b/flowming/src/components/Flow/Nodes/AssignVariable.tsx @@ -65,17 +65,26 @@ export class AssignVariableProcessor implements NodeProcessor { } const AssignVariable = memo(function AssignVariableComponent({ data, id: _nodeId }: { data: AssignVariableNode; id: string }) { - const { isHovered, isSelected, isHighlighted, isCodeHighlighted, expression, width, height, visualId, isError, hasBreakpoint, isBreakpointTriggered, leftSideIndex } = data as AssignVariableNode & { leftSideIndex?: string }; + const { isHovered, isSelected, isHighlighted, isCodeHighlighted, expression, width, height, visualId, isError, hasBreakpoint, isBreakpointTriggered } = data as AssignVariableNode; const buildDisplayString = () => { if (!expression) return null; const exprInstance = expression instanceof Expression ? expression : Expression.fromObject(expression); + + // When the stored expression is effectively empty (no left or right side) + if (exprInstance.isEmpty()) return null; + // For assign variable expressions, leftSide is a Variable if (exprInstance.leftSide instanceof Variable) { - const leftName = exprInstance.leftSide.name; - const leftDisplay = leftSideIndex ? `${leftName}[${leftSideIndex}]` : leftName; + const leftVar = exprInstance.leftSide; + let leftDisplay = leftVar.name; + if (leftVar.indexExpression && leftVar.indexExpression.length > 0) { + const idxStr = new Expression(undefined, leftVar.indexExpression as any).toString(); + leftDisplay = `${leftVar.name}[${idxStr}]`; + } const right = exprInstance.rightSide.map((e: any) => (e as ExpressionElement).toString()).join(' '); - return `${leftDisplay} = ${right}`; + const str = `${leftDisplay} = ${right}`; + return str.trim() === '' ? null : str; } // Fallback return exprInstance.toString(); @@ -124,7 +133,7 @@ const AssignVariable = memo(function AssignVariableComponent({ data, id: _nodeId )} - {expression ? ( + {displayString ? (
{displayString} diff --git a/flowming/src/components/Flow/Nodes/Input.tsx b/flowming/src/components/Flow/Nodes/Input.tsx index 6e61f19..30c0461 100644 --- a/flowming/src/components/Flow/Nodes/Input.tsx +++ b/flowming/src/components/Flow/Nodes/Input.tsx @@ -2,10 +2,13 @@ import { Handle, Position, ReactFlowInstance } from '@xyflow/react'; import { memo } from 'react'; import { getNodeStyles } from '../../../utils/nodeStyles'; import { BaseNode, NodeProcessor } from './NodeTypes'; -import { IVariable, VariableType, Variable } from '../../../models/Variable'; +import { VariableType, Variable } from '../../../models/Variable'; +import { IVariable } from '../../../models/IVariable'; import { IValuedVariable, ValuedVariable } from '../../../models/ValuedVariable'; import { Badge } from '@/components/ui/badge'; import BreakpointIndicator from './BreakpointIndicator'; +import { Expression } from '../../../models/Expression'; +import { ExpressionElement } from '../../../models/ExpressionElement'; interface InputNode extends BaseNode { variable?: IVariable; @@ -39,7 +42,7 @@ export class InputProcessor implements NodeProcessor { if (data.variable) { const input = await this.showInputDialog( `Enter value for ${data.variable.name}`, - data.variable.type as 'string' | 'integer' | 'float' | 'boolean', // TODO: array + (data.variable.type === 'array' ? data.variable.arraySubtype : data.variable.type) as 'string' | 'integer' | 'float' | 'boolean', `Please enter a ${data.variable.type} value for the variable "${data.variable.name}" (Block ID: ${data.visualId}).`, `Enter ${data.variable.type} value...` ); @@ -47,7 +50,8 @@ export class InputProcessor implements NodeProcessor { if (input !== null && data.variable) { // Convert the raw string input to the correct type based on the variable definition let convertedValue: any = input; - switch (data.variable.type) { + const valueType = data.variable.type === 'array' ? data.variable.arraySubtype : data.variable.type; + switch (valueType) { case 'integer': { const parsed = parseInt(input, 10); if (isNaN(parsed)) { @@ -80,14 +84,59 @@ export class InputProcessor implements NodeProcessor { // Convert IVariable to Variable before passing to fromVariable const variableObj = Variable.fromObject(data.variable); - const newValuedVariable = ValuedVariable.fromVariable(variableObj, convertedValue); - - // Overwrite the existing variable - const existingIndex = currentValuedVariables.findIndex(v => v.id === newValuedVariable.id); - if (existingIndex !== -1) { - currentValuedVariables[existingIndex] = newValuedVariable; + + // If variable is an array and has an index expression, handle element assignment + if (variableObj.type === 'array' && variableObj.indexExpression && variableObj.indexExpression.length > 0) { + try { + // Evaluate index expression (must resolve to integer) + const indexExpr = new Expression(undefined, variableObj.indexExpression); + const idx = indexExpr.calculateValue(variableObj.indexExpression, 'integer', currentValuedVariables); + + if (typeof idx !== 'number' || !Number.isInteger(idx)) { + throw new Error(`Array index for variable "${variableObj.name}" must evaluate to an integer.`); + } + + // Locate existing valued variable or create a default one + let existingVarIdx = currentValuedVariables.findIndex(v => v.id === variableObj.id); + let existingValuedVar: ValuedVariable; + if (existingVarIdx !== -1) { + existingValuedVar = currentValuedVariables[existingVarIdx]; + } else { + existingValuedVar = ValuedVariable.fromVariable(variableObj, null); + } + + // Ensure array is initialized with correct size + const arraySize = variableObj.arraySize || (Array.isArray(existingValuedVar.value) ? existingValuedVar.value.length : 0); + if (idx < 0 || idx >= arraySize) { + throw new Error(`Array index ${idx} is out of bounds for "${variableObj.name}" (size ${arraySize}).`); + } + + // Create updated array value + const updatedArray = Array.isArray(existingValuedVar.value) ? [...existingValuedVar.value] : new Array(arraySize).fill(null); + updatedArray[idx] = convertedValue; + + const newValuedVariable = new ValuedVariable(variableObj.id, variableObj.type, variableObj.name, variableObj.nodeId, updatedArray, variableObj.arraySubtype, variableObj.arraySize); + + if (existingVarIdx !== -1) { + currentValuedVariables[existingVarIdx] = newValuedVariable; + } else { + currentValuedVariables.push(newValuedVariable); + } + } catch (err) { + console.error(err); + throw err; + } } else { - currentValuedVariables.push(newValuedVariable); + // Scalar variable assignment + const newValuedVariable = ValuedVariable.fromVariable(variableObj, convertedValue); + + // Overwrite the existing variable + const existingIndex = currentValuedVariables.findIndex(v => v.id === newValuedVariable.id); + if (existingIndex !== -1) { + currentValuedVariables[existingIndex] = newValuedVariable; + } else { + currentValuedVariables.push(newValuedVariable); + } } } } @@ -99,6 +148,20 @@ export class InputProcessor implements NodeProcessor { const Input = memo(function InputComponent({ data, id: _nodeId }: { data: InputNode; id: string }) { const { isHovered, isSelected, isHighlighted, isCodeHighlighted, variable, width, height, visualId, isError, hasBreakpoint, isBreakpointTriggered } = data; + // Helper to stringify index expression for display + const getIndexString = (indexExpr?: any[]): string | null => { + if (!indexExpr || indexExpr.length === 0) return null; + try { + // Ensure all elements are ExpressionElement instances + const elems = (indexExpr as any[]).map((e: any): ExpressionElement => + e instanceof ExpressionElement ? e : ExpressionElement.fromObject(e) + ); + return new Expression(undefined, elems as any).toString(); + } catch { + return null; + } + }; + return (
- {variable.name} = {variable.type}(👤) + {variable.name} + {variable.type === 'array' && ( + <> + [ + {getIndexString(variable.indexExpression) || ''} + ] + + )} + {' '}= {(variable.type === 'array' ? variable.arraySubtype : variable.type)}(👤)
) : ( diff --git a/flowming/src/components/ImportExport.tsx b/flowming/src/components/ImportExport.tsx index 6f0dc59..44f1126 100644 --- a/flowming/src/components/ImportExport.tsx +++ b/flowming/src/components/ImportExport.tsx @@ -4,7 +4,7 @@ import { useVariables } from '../context/VariablesContext'; import { useFilename } from '../context/FilenameContext'; import { useFlowExecutorActions, useFlowExecutorState } from '../context/FlowExecutorContext'; import { SelectedNodeContext } from '../context/SelectedNodeContext'; -import { Variable } from '../models'; +import { Variable, ExpressionElement } from '../models'; import { Button } from './ui/button'; import { Download, Upload, FileX } from 'lucide-react'; import { useDebugger } from '../context/DebuggerContext'; @@ -117,6 +117,9 @@ const ImportExport: React.FC = () => { // Stop execution before importing stop(); + // Reset selected node so DetailsTab shows System Settings during import + setSelectedNode(null); + const input = document.createElement('input'); input.type = 'file'; input.accept = '.flowming'; @@ -172,7 +175,14 @@ const ImportExport: React.FC = () => { }); } - setNodes(flowData.nodes || []); + // Restore nodes and convert any indexExpression objects back to ExpressionElement instances + const restoredNodes = (flowData.nodes || []).map((n: any) => { + if (n.type === 'Input' && n.data?.variable?.indexExpression) { + n.data.variable.indexExpression = n.data.variable.indexExpression.map((elem: any) => ExpressionElement.fromObject(elem)); + } + return n; + }); + setNodes(restoredNodes); setEdges(flowData.edges || []); // Fit and center the view for the imported diagram @@ -189,7 +199,7 @@ const ImportExport: React.FC = () => { }; input.click(); - }, [setNodes, setEdges, getNodes, updateNodeVariables, deleteNodeVariables, fitView, setFilename, stop]); + }, [setNodes, setEdges, getNodes, updateNodeVariables, deleteNodeVariables, fitView, setFilename, stop, setSelectedNode]); return (
diff --git a/flowming/src/components/Panel/Tabs/DebuggerTab.tsx b/flowming/src/components/Panel/Tabs/DebuggerTab.tsx index f982eaf..76b1107 100644 --- a/flowming/src/components/Panel/Tabs/DebuggerTab.tsx +++ b/flowming/src/components/Panel/Tabs/DebuggerTab.tsx @@ -47,6 +47,22 @@ const DebuggerTab = () => { } }; + /** + * Formats a variable value for display. + * Arrays are shown with brackets and comma-separated elements (e.g., [1, 2, 3]). + * Non-arrays are stringified as-is. + */ + const formatValue = (value: any): string => { + if (Array.isArray(value)) { + return `[${value.map(v => formatValue(v)).join(', ')}]`; + } + // Properly format strings with quotes + if (typeof value === 'string') { + return JSON.stringify(value); + } + return String(value); + }; + return (
{/* Status header */} @@ -111,7 +127,7 @@ const DebuggerTab = () => { {variable.name}: - {variable.value} + {formatValue(variable.value)}
@@ -144,7 +160,7 @@ const DebuggerTab = () => { → - {change.value} + {formatValue(change.value)}
diff --git a/flowming/src/components/Panel/Tabs/editors/ConditionalEditor.tsx b/flowming/src/components/Panel/Tabs/editors/ConditionalEditor.tsx index 91e867c..ff1cbb8 100644 --- a/flowming/src/components/Panel/Tabs/editors/ConditionalEditor.tsx +++ b/flowming/src/components/Panel/Tabs/editors/ConditionalEditor.tsx @@ -44,7 +44,6 @@ import { ExpressionDropArea, } from './shared/DragAndDropComponents'; import ArrayIndexDialog from '@/components/ui/ArrayIndexDialog'; -import { parseArrayAccess, parseExpressionString } from '@/utils/expressionParsing'; // Available operators for expression building const operators = [ @@ -104,10 +103,11 @@ interface FunctionExpressionElementProps { removeExpressionElement: (id: string) => void; disabled: boolean; side: 'left' | 'right'; + onEdit?: (element: ExpressionElement) => void; } // Function block component for nesting -const FunctionExpressionElement: React.FC = ({ element, removeExpressionElement, disabled, side }) => { +const FunctionExpressionElement: React.FC = ({ element, removeExpressionElement, disabled, side, onEdit }) => { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: element.id, disabled }); const nestedDropId = `nested-${side}-${element.id}`; @@ -141,6 +141,7 @@ const FunctionExpressionElement: React.FC = ({ e removeExpressionElement={removeExpressionElement} disabled={disabled} side={side} + onEdit={onEdit} /> ) : ( = ({ e index={idx} removeExpressionElement={removeExpressionElement} disabled={disabled} + onEdit={onEdit} /> ) )) @@ -188,9 +190,7 @@ const ConditionalEditor = () => { // State for editing existing array access elements const [editingElement, setEditingElement] = useState(null); const [initialExpression, setInitialExpression] = useState(null); - const [initialRangeStart, setInitialRangeStart] = useState(null); - const [initialRangeEnd, setInitialRangeEnd] = useState(null); - const [initialTab, setInitialTab] = useState<'single' | 'range'>('single'); + const [initialTab, setInitialTab] = useState<'single'>('single'); const reactFlowInstance = useReactFlow(); @@ -320,7 +320,26 @@ const ConditionalEditor = () => { setExpression(prev => { if (!prev) return null; const newExpr = prev.clone(); + + // Remove from right side using built-in helper newExpr.removeElement(id); + + // Remove from left side (array of ExpressionElement) + if (Array.isArray(newExpr.leftSide)) { + const recursiveRemove = (list: ExpressionElement[]): ExpressionElement[] => { + return list.flatMap(e => { + if (e.id === id) { + return []; + } + if (e.isFunction() && e.nestedExpression) { + e.nestedExpression.rightSide = recursiveRemove(e.nestedExpression.rightSide); + } + return [e]; + }); + }; + newExpr.leftSide = recursiveRemove(newExpr.leftSide); + } + return newExpr; }); } @@ -460,7 +479,7 @@ const ConditionalEditor = () => { } // Intercept array variable drag - if (activeDraggableItem.type === 'variable' && activeDraggableItem.variable?.type === 'array') { + if (activeDraggableItem.type === 'variable' && activeDraggableItem.variable?.type === 'array' && (!activeDraggableItem.variable.indexExpression || activeDraggableItem.variable.indexExpression.length === 0)) { const overLocation = getElementLocationInfo(over.id, expression); if (overLocation && overLocation.side && (overLocation.isMainDropArea || overLocation.isNestedDropArea || overLocation.isMainExpressionElement || overLocation.isNestedExpressionElement)) { setArrayVariableForIndex(activeDraggableItem.variable); @@ -574,7 +593,7 @@ const ConditionalEditor = () => { setExpression(prev => { if (!prev) return null; const newExpr = prev.clone(); - if (indexSide === 'left' && Array.isArray(newExpr.leftSide)) { + if (side === 'left' && Array.isArray(newExpr.leftSide)) { newExpr.leftSide.push(element as any); } else { newExpr.rightSide.push(element); @@ -586,14 +605,9 @@ const ConditionalEditor = () => { // Handle editing array access elements const handleEditArrayAccess = (element: ExpressionElement) => { - if (!element.value.includes('[') || !element.value.includes(']')) return; - - const parsed = parseArrayAccess(element.value); - if (!parsed) return; - - // Find the array variable - const arrayVar = getAllVariables().find(v => v.name === parsed.arrayName && v.type === 'array'); - if (!arrayVar) return; + if (!element.variable || !(element.variable instanceof Variable)) return; + const elemVar = element.variable as Variable; + if (elemVar.type !== 'array') return; // Determine which side the element is on let elementSide: 'left' | 'right' = 'right'; @@ -605,20 +619,14 @@ const ConditionalEditor = () => { } setEditingElement(element); - setArrayVariableForIndex(arrayVar); + setArrayVariableForIndex(elemVar); setIndexSide(elementSide); - if (parsed.isRange && parsed.rangeStart && parsed.rangeEnd) { - setInitialTab('range'); - setInitialRangeStart(parseExpressionString(parsed.rangeStart, getAllVariables())); - setInitialRangeEnd(parseExpressionString(parsed.rangeEnd, getAllVariables())); - setInitialExpression(null); - } else if (parsed.indexExpression) { - setInitialTab('single'); - setInitialExpression(parseExpressionString(parsed.indexExpression, getAllVariables())); - setInitialRangeStart(null); - setInitialRangeEnd(null); - } + setInitialTab('single'); + const idxExpr = elemVar.indexExpression && elemVar.indexExpression.length > 0 + ? elemVar.indexExpression.map(e => e.clone()) + : []; + setInitialExpression(new Expression(undefined, idxExpr)); setIsIndexDialogOpen(true); }; @@ -626,63 +634,47 @@ const ConditionalEditor = () => { // Handle dialog submission for editing const handleDialogSubmit = (_: 'single', expr: Expression) => { if (arrayVariableForIndex && expression) { - const exprStr = expr.toString(); - const newValue = `${arrayVariableForIndex.name}[${exprStr}]`; - - if (editingElement) { - // Replace existing element - setExpression(prev => { - if (!prev) return null; - const newExpr = prev.clone(); - const elementToUpdate = newExpr.findElement(editingElement.id); - if (elementToUpdate) { - elementToUpdate.value = newValue; - } - return newExpr; - }); - } else { - // Add new element - const element = new ExpressionElement(crypto.randomUUID(), 'literal', newValue); - setExpression(prev => { - if (!prev) return null; - const newExpr = prev.clone(); - if (indexSide === 'left' && Array.isArray(newExpr.leftSide)) { - newExpr.leftSide.push(element as any); - } else { - newExpr.rightSide.push(element); - } - return newExpr; - }); + if (expr.isEmpty()) { + // Ignore empty index + setEditingElement(null); + setIsIndexDialogOpen(false); + setArrayVariableForIndex(null); + return; } - } - - // Reset state - setEditingElement(null); - setInitialExpression(null); - setInitialRangeStart(null); - setInitialRangeEnd(null); - setIsIndexDialogOpen(false); - }; - - // Handle dialog submission for range editing - const handleDialogSubmitRange = (_: 'range', start: Expression, end: Expression) => { - if (arrayVariableForIndex && expression) { - const newValue = `${arrayVariableForIndex.name}[${start.toString()}:${end.toString()}]`; + // Build variable clone with index expression directly + const idxExpr = expr.rightSide.map(e => e.clone()); + const variableClone = arrayVariableForIndex.clone(); + (variableClone as any).indexExpression = idxExpr; if (editingElement) { - // Replace existing element + // Replace existing element (search both sides) setExpression(prev => { if (!prev) return null; const newExpr = prev.clone(); - const elementToUpdate = newExpr.findElement(editingElement.id); - if (elementToUpdate) { - elementToUpdate.value = newValue; + // TODO: refactor findElement? + const findInList = (list: ExpressionElement[]): ExpressionElement | undefined => { + for (const el of list) { + if (el.id === editingElement!.id) return el; + if (el.isFunction() && el.nestedExpression) { + const found = findInList(el.nestedExpression.rightSide); + if (found) return found; + } + } + return undefined; + }; + let elem = newExpr.findElement(editingElement!.id); + if (!elem && Array.isArray(newExpr.leftSide)) { + elem = findInList(newExpr.leftSide); + } + if (elem) { + elem.type = 'variable'; + elem.setVariable(variableClone); } return newExpr; }); } else { // Add new element - const element = new ExpressionElement(crypto.randomUUID(), 'literal', newValue); + const element = new ExpressionElement(crypto.randomUUID(), 'variable', '', variableClone); setExpression(prev => { if (!prev) return null; const newExpr = prev.clone(); @@ -699,8 +691,6 @@ const ConditionalEditor = () => { // Reset state setEditingElement(null); setInitialExpression(null); - setInitialRangeStart(null); - setInitialRangeEnd(null); setIsIndexDialogOpen(false); }; @@ -708,8 +698,6 @@ const ConditionalEditor = () => { const handleDialogCancel = () => { setEditingElement(null); setInitialExpression(null); - setInitialRangeStart(null); - setInitialRangeEnd(null); setIsIndexDialogOpen(false); }; @@ -752,6 +740,7 @@ const ConditionalEditor = () => { removeExpressionElement={removeExpressionElement} disabled={isRunning} side='left' + onEdit={handleEditArrayAccess} /> ); } @@ -815,6 +804,7 @@ const ConditionalEditor = () => { removeExpressionElement={removeExpressionElement} disabled={isRunning} side='right' + onEdit={handleEditArrayAccess} /> ) : ( { px-2 py-1 m-1 rounded text-sm shadow-lg cursor-grabbing ${activeDraggableItem.type === 'variable' ? 'bg-blue-100' : activeDraggableItem.type === 'operator' ? 'bg-red-100' : - activeDraggableItem.type === 'literal' && activeDraggableItem.value.includes('[') && activeDraggableItem.value.includes(']') ? 'bg-blue-100' : activeDraggableItem.type === 'literal' ? 'bg-green-100' : 'bg-purple-100'} `}> {activeDraggableItem.value} @@ -1195,10 +1184,10 @@ const ConditionalEditor = () => { variableName={arrayVariableForIndex?.name || ''} onCancel={handleDialogCancel} onSubmit={handleDialogSubmit} - onSubmitRange={handleDialogSubmitRange} + onSubmitRange={undefined as any} initialExpression={initialExpression || undefined} - initialRangeStart={initialRangeStart || undefined} - initialRangeEnd={initialRangeEnd || undefined} + initialRangeStart={undefined as any} + initialRangeEnd={undefined as any} initialTab={initialTab} /> diff --git a/flowming/src/components/Panel/Tabs/editors/InputEditor.tsx b/flowming/src/components/Panel/Tabs/editors/InputEditor.tsx index 94dcfc8..29d833b 100644 --- a/flowming/src/components/Panel/Tabs/editors/InputEditor.tsx +++ b/flowming/src/components/Panel/Tabs/editors/InputEditor.tsx @@ -12,6 +12,10 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { Button } from "@/components/ui/button"; +import ArrayIndexDialog from "@/components/ui/ArrayIndexDialog"; +import { Variable } from "../../../../models/Variable"; +import { Expression, ExpressionElement } from "../../../../models"; const InputEditor = () => { const { selectedNode } = useContext(SelectedNodeContext); @@ -23,6 +27,18 @@ const InputEditor = () => { const reactFlowInstance = useReactFlow(); const { isRunning } = useFlowExecutorState(); + // Additional state for array index handling + // leftSideIndexExprElems stores the structured index expression (array element tokens) + const [leftSideIndexExprElems, setLeftSideIndexExprElems] = useState(null); + const [isIndexDialogOpen, setIsIndexDialogOpen] = useState(false); + const [arrayVariableForIndex, setArrayVariableForIndex] = useState(null); + const [initialExpression, setInitialExpression] = useState(null); + const [initialRangeStart, setInitialRangeStart] = useState(null); + const [initialRangeEnd, setInitialRangeEnd] = useState(null); + const [initialTab, setInitialTab] = useState<'single' | 'range'>('single'); + // Track whether the dialog is editing the index of the currently selected variable + const [isEditingIndex, setIsEditingIndex] = useState(false); + // Update the node data when variable changes for Input node useEffect(() => { if (selectedNode?.type === 'Input' && !isInitialLoadRef.current) { @@ -31,8 +47,15 @@ const InputEditor = () => { let updatedData; if (selectedVariable) { + let variableClone = selectedVariable.clone(); + + // Attach index expression if applicable (array variable with index) + if (selectedVariable.type === 'array' && leftSideIndexExprElems && leftSideIndexExprElems.length > 0) { + (variableClone as any).indexExpression = leftSideIndexExprElems.map(e => e.clone()); + } + updatedData = { - variable: selectedVariable + variable: variableClone, }; } else { updatedData = { variable: null }; @@ -41,7 +64,7 @@ const InputEditor = () => { // Update the node data reactFlowInstance.updateNodeData(selectedNode.id, updatedData); } - }, [leftSideVariable, reactFlowInstance, selectedNode, getAllVariables]); + }, [leftSideVariable, leftSideIndexExprElems, reactFlowInstance, selectedNode, getAllVariables]); // Load input data when the selected node changes or when its data changes (for collaboration) useEffect(() => { @@ -62,57 +85,216 @@ const InputEditor = () => { // Initialize with existing variable if available if (selectedNode.data.variable && selectedNode.data.variable.id) { setLeftSideVariable(selectedNode.data.variable.id); + + // Load stored index if present (array variables) + if (selectedNode.data.variable.indexExpression && selectedNode.data.variable.indexExpression.length > 0) { + try { + // Ensure every element is an ExpressionElement instance (can be plain objects after Yjs sync) + const elems = selectedNode.data.variable.indexExpression.map((e: any): ExpressionElement => { + return e instanceof ExpressionElement ? e : ExpressionElement.fromObject(e); + }); + setLeftSideIndexExprElems(elems.map((el: ExpressionElement) => el.clone())); + } catch { + setLeftSideIndexExprElems(null); + } + } else { + setLeftSideIndexExprElems(null); + } } else if (nodeChanged) { // Only reset when it's a new node (not when collaborative data is cleared) setLeftSideVariable(''); + setLeftSideIndexExprElems(null); } } } else { previousNodeIdRef.current = null; setLeftSideVariable(''); + setLeftSideIndexExprElems(null); } isInitialLoadRef.current = false; }, [selectedNode, selectedNode?.data?.variable]); + // Updated handler for variable change to support arrays + const handleVariableChange = (value: string) => { + if (value === '__CLEAR__') { + setLeftSideVariable(''); + setLeftSideIndexExprElems(null); + if (selectedNode) { + reactFlowInstance.updateNodeData(selectedNode.id, { variable: null }); + } + return; + } + + const variable = getAllVariables().find(v => v.id === value); + if (!variable) return; + + setLeftSideVariable(variable.id); + + if (variable.type === 'array') { + setArrayVariableForIndex(variable); + setIsEditingIndex(true); + + // Prefill dialog if index already exists + if (leftSideIndexExprElems && leftSideIndexExprElems.length > 0) { + setInitialTab('single'); + setInitialExpression(new Expression(undefined, leftSideIndexExprElems.map(e => e.clone()))); + setInitialRangeStart(null); + setInitialRangeEnd(null); + } else { + setInitialTab('single'); + setInitialExpression(new Expression(undefined, [])); + } + + // Defer opening the dialog until after the Select dropdown has closed + setTimeout(() => setIsIndexDialogOpen(true), 0); + } else { + setLeftSideIndexExprElems(null); + // Update node data immediately for non-array variables + const variableClone = variable.clone(); + reactFlowInstance.updateNodeData(selectedNode!.id, { variable: variableClone }); + } + }; + + // Dialog submit handlers + const handleDialogSubmitSingle = (_: 'single', expr: Expression) => { + if (!arrayVariableForIndex || !selectedNode) return; + + if (expr.isEmpty()) { + // Treat empty as cancel + handleDialogCancel(); + return; + } + + setLeftSideIndexExprElems(expr.rightSide.map(e => e.clone())); + + const variableClone = arrayVariableForIndex.clone(); + (variableClone as any).indexExpression = expr.rightSide.map(e => e.clone()); + + reactFlowInstance.updateNodeData(selectedNode.id, { variable: variableClone }); + + setIsIndexDialogOpen(false); + setArrayVariableForIndex(null); + setIsEditingIndex(false); + }; + + const handleDialogSubmitRange = (_: 'range', start: Expression, end: Expression) => { + // For Input block we treat range the same as single expression string representation + if (!arrayVariableForIndex || !selectedNode) return; + + if (start.isEmpty() || end.isEmpty()) { + handleDialogCancel(); + return; + } + + // Range not supported for actual element assignment in Input node; store as string only + const variableClone = arrayVariableForIndex.clone(); + reactFlowInstance.updateNodeData(selectedNode.id, { variable: variableClone }); + + setIsIndexDialogOpen(false); + setArrayVariableForIndex(null); + setIsEditingIndex(false); + }; + + const handleDialogCancel = () => { + setIsIndexDialogOpen(false); + setArrayVariableForIndex(null); + + if (isEditingIndex && (!leftSideIndexExprElems || leftSideIndexExprElems.length === 0)) { + // User canceled before providing an index -> deselect variable + setLeftSideVariable(''); + } + + setIsEditingIndex(false); + }; + // Don't render if not an Input node if (!selectedNode || selectedNode.type !== 'Input') return null; const allVariables = getAllVariables(); return ( -
- - {/* Variable selection */} - - - Input Variable - - - - -
- During execution, the program will prompt for this variable's value. -
-
-
-
+ <> + {/* Array index dialog */} + + +
+ + {/* Variable selection */} + + + Input Variable + + +
+ + {/* Index display / edit button for arrays */} + {(() => { + const selectedVar = allVariables.find(v => v.id === leftSideVariable); + if (selectedVar && selectedVar.type === 'array') { + return ( + + ); + } + return null; + })()} +
+ +
+ During execution, the program will prompt for this variable's value. +
+
+
+
+ ); }; diff --git a/flowming/src/components/Panel/Tabs/editors/OutputEditor.tsx b/flowming/src/components/Panel/Tabs/editors/OutputEditor.tsx index 9d09624..fecc887 100644 --- a/flowming/src/components/Panel/Tabs/editors/OutputEditor.tsx +++ b/flowming/src/components/Panel/Tabs/editors/OutputEditor.tsx @@ -37,7 +37,6 @@ import { } from './shared/DragAndDropComponents'; import { CSS } from '@dnd-kit/utilities'; import ArrayIndexDialog from '@/components/ui/ArrayIndexDialog'; -import { parseArrayAccess, parseExpressionString } from '@/utils/expressionParsing'; // Available operators for expression building const operators = [ @@ -67,10 +66,11 @@ interface FunctionExpressionElementProps { element: ExpressionElement; removeExpressionElement: (id: string) => void; disabled: boolean; + onEdit?: (element: ExpressionElement) => void; } // Function block component for nesting -const FunctionExpressionElement: React.FC = ({ element, removeExpressionElement, disabled }) => { +const FunctionExpressionElement: React.FC = ({ element, removeExpressionElement, disabled, onEdit }) => { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: element.id, disabled }); const nestedDropId = `nested-${element.id}`; @@ -103,6 +103,7 @@ const FunctionExpressionElement: React.FC = ({ e element={nestedElem} removeExpressionElement={removeExpressionElement} disabled={disabled} + onEdit={onEdit} /> ) : ( = ({ e index={idx} removeExpressionElement={removeExpressionElement} disabled={disabled} + onEdit={onEdit} /> ) )) @@ -149,9 +151,7 @@ const OutputEditor = () => { // State for editing existing array access elements const [editingElement, setEditingElement] = useState(null); const [initialExpression, setInitialExpression] = useState(null); - const [initialRangeStart, setInitialRangeStart] = useState(null); - const [initialRangeEnd, setInitialRangeEnd] = useState(null); - const [initialTab, setInitialTab] = useState<'single' | 'range'>('single'); + const [initialTab, setInitialTab] = useState<'single'>('single'); const reactFlowInstance = useReactFlow(); @@ -399,7 +399,7 @@ const OutputEditor = () => { } // Intercept array variable drag - if (activeDraggableItem.type === 'variable' && activeDraggableItem.variable?.type === 'array') { + if (activeDraggableItem.type === 'variable' && activeDraggableItem.variable?.type === 'array' && (!activeDraggableItem.variable.indexExpression || activeDraggableItem.variable.indexExpression.length === 0)) { const overLocation = getElementLocationInfo(over.id, expression); // Check if it's a valid drop target if (overLocation && (overLocation.isMainDropArea || overLocation.isNestedDropArea || overLocation.isMainExpressionElement || overLocation.isNestedExpressionElement)) { @@ -513,29 +513,19 @@ const OutputEditor = () => { // Handle editing array access elements const handleEditArrayAccess = (element: ExpressionElement) => { - if (!element.value.includes('[') || !element.value.includes(']')) return; - - const parsed = parseArrayAccess(element.value); - if (!parsed) return; - - // Find the array variable - const arrayVar = getAllVariables().find(v => v.name === parsed.arrayName && v.type === 'array'); - if (!arrayVar) return; + if (!element.variable || !(element.variable instanceof Variable)) return; + const elemVar = element.variable as Variable; + if (elemVar.type !== 'array') return; + const arrayVar = getAllVariables().find(v => v.id === elemVar.id && v.type === 'array') || elemVar; + setEditingElement(element); setArrayVariableForIndex(arrayVar); - - if (parsed.isRange && parsed.rangeStart && parsed.rangeEnd) { - setInitialTab('range'); - setInitialRangeStart(parseExpressionString(parsed.rangeStart, getAllVariables())); - setInitialRangeEnd(parseExpressionString(parsed.rangeEnd, getAllVariables())); - setInitialExpression(null); - } else if (parsed.indexExpression) { - setInitialTab('single'); - setInitialExpression(parseExpressionString(parsed.indexExpression, getAllVariables())); - setInitialRangeStart(null); - setInitialRangeEnd(null); - } + setInitialTab('single'); + const idxExpr = elemVar.indexExpression && elemVar.indexExpression.length > 0 + ? elemVar.indexExpression.map(e => e.clone()) + : []; + setInitialExpression(new Expression(undefined, idxExpr)); setIsIndexDialogOpen(true); }; @@ -543,44 +533,18 @@ const OutputEditor = () => { // Handle dialog submission for editing const handleDialogSubmit = (_: 'single', expr: Expression) => { if (arrayVariableForIndex) { - const exprStr = expr.toString(); - const newValue = `${arrayVariableForIndex.name}[${exprStr}]`; - - if (editingElement) { - // Replace existing element - setExpression(prev => { - if (!prev) return null; - const newExpr = prev.clone(); - const elementToUpdate = newExpr.findElement(editingElement.id); - if (elementToUpdate) { - elementToUpdate.value = newValue; - } - return newExpr; - }); - } else { - // Add new element - const element = new ExpressionElement(crypto.randomUUID(), 'literal', newValue); - setExpression(prev => { - if (!prev) return null; - const newExpr = prev.clone(); - newExpr.addElement(element); - return newExpr; - }); + if (expr.isEmpty()) { + // Ignore empty index + // TODO: ideally error message and block action + setEditingElement(null); + setIsIndexDialogOpen(false); + setArrayVariableForIndex(null); + return; } - } - - // Reset state - setEditingElement(null); - setInitialExpression(null); - setInitialRangeStart(null); - setInitialRangeEnd(null); - setIsIndexDialogOpen(false); - }; - - // Handle dialog submission for range editing - const handleDialogSubmitRange = (_: 'range', start: Expression, end: Expression) => { - if (arrayVariableForIndex) { - const newValue = `${arrayVariableForIndex.name}[${start.toString()}:${end.toString()}]`; + // Build variable clone with index expression directly + const idxExpr = expr.rightSide.map(e => e.clone()); + const variableClone = arrayVariableForIndex.clone(); + (variableClone as any).indexExpression = idxExpr; if (editingElement) { // Replace existing element @@ -589,13 +553,14 @@ const OutputEditor = () => { const newExpr = prev.clone(); const elementToUpdate = newExpr.findElement(editingElement.id); if (elementToUpdate) { - elementToUpdate.value = newValue; + elementToUpdate.type = 'variable'; + elementToUpdate.setVariable(variableClone); } return newExpr; }); } else { // Add new element - const element = new ExpressionElement(crypto.randomUUID(), 'literal', newValue); + const element = new ExpressionElement(crypto.randomUUID(), 'variable', '', variableClone); setExpression(prev => { if (!prev) return null; const newExpr = prev.clone(); @@ -608,8 +573,6 @@ const OutputEditor = () => { // Reset state setEditingElement(null); setInitialExpression(null); - setInitialRangeStart(null); - setInitialRangeEnd(null); setIsIndexDialogOpen(false); }; @@ -617,8 +580,6 @@ const OutputEditor = () => { const handleDialogCancel = () => { setEditingElement(null); setInitialExpression(null); - setInitialRangeStart(null); - setInitialRangeEnd(null); setIsIndexDialogOpen(false); }; @@ -656,6 +617,7 @@ const OutputEditor = () => { element={element} removeExpressionElement={removeExpressionElement} disabled={isRunning} + onEdit={handleEditArrayAccess} /> ); } @@ -905,7 +867,6 @@ const OutputEditor = () => { px-2 py-1 m-1 rounded text-sm shadow-lg cursor-grabbing ${activeDraggableItem.type === 'variable' ? 'bg-blue-100' : activeDraggableItem.type === 'operator' ? 'bg-red-100' : - activeDraggableItem.type === 'literal' && activeDraggableItem.value.includes('[') && activeDraggableItem.value.includes(']') ? 'bg-blue-100' : activeDraggableItem.type === 'literal' ? 'bg-green-100' : 'bg-purple-100'} `}> {activeDraggableItem.value} @@ -919,10 +880,10 @@ const OutputEditor = () => { variableName={arrayVariableForIndex?.name || ''} onCancel={handleDialogCancel} onSubmit={handleDialogSubmit} - onSubmitRange={handleDialogSubmitRange} + onSubmitRange={undefined as any} initialExpression={initialExpression || undefined} - initialRangeStart={initialRangeStart || undefined} - initialRangeEnd={initialRangeEnd || undefined} + initialRangeStart={undefined as any} + initialRangeEnd={undefined as any} initialTab={initialTab} /> diff --git a/flowming/src/components/Panel/Tabs/editors/VariableAssignmentEditor.tsx b/flowming/src/components/Panel/Tabs/editors/VariableAssignmentEditor.tsx index 82d3c7e..8767173 100644 --- a/flowming/src/components/Panel/Tabs/editors/VariableAssignmentEditor.tsx +++ b/flowming/src/components/Panel/Tabs/editors/VariableAssignmentEditor.tsx @@ -44,7 +44,6 @@ import { ExpressionDropArea } from './shared/DragAndDropComponents'; import ArrayIndexDialog from '@/components/ui/ArrayIndexDialog'; -import { parseArrayAccess, parseExpressionString } from '@/utils/expressionParsing'; // Available operators for expression building const operators = [ @@ -74,10 +73,11 @@ interface FunctionExpressionElementProps { element: ExpressionElement; removeExpressionElement: (id: string) => void; disabled: boolean; + onEdit?: (element: ExpressionElement) => void; } // Function block component for nesting -const FunctionExpressionElement: React.FC = ({ element, removeExpressionElement, disabled }) => { +const FunctionExpressionElement: React.FC = ({ element, removeExpressionElement, disabled, onEdit }) => { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: element.id, disabled }); const nestedDropId = `nested-${element.id}`; @@ -110,6 +110,7 @@ const FunctionExpressionElement: React.FC = ({ e element={nestedElem} removeExpressionElement={removeExpressionElement} disabled={disabled} + onEdit={onEdit} /> ) : ( = ({ e index={idx} removeExpressionElement={removeExpressionElement} disabled={disabled} + onEdit={onEdit} /> ) )) @@ -154,15 +156,13 @@ const VariableAssignmentEditor = () => { const [isIndexDialogOpen, setIsIndexDialogOpen] = useState(false); const [arrayVariableForIndex, setArrayVariableForIndex] = useState(null); // Left-side (assignment target) index state - const [leftSideIndexString, setLeftSideIndexString] = useState(null); + const [leftSideIndexExprElems, setLeftSideIndexExprElems] = useState(null); const [isEditingLeftSideIndex, setIsEditingLeftSideIndex] = useState(false); // State for editing existing array access elements const [editingElement, setEditingElement] = useState(null); const [initialExpression, setInitialExpression] = useState(null); - const [initialRangeStart, setInitialRangeStart] = useState(null); - const [initialRangeEnd, setInitialRangeEnd] = useState(null); - const [initialTab, setInitialTab] = useState<'single' | 'range'>('single'); + const [initialTab, setInitialTab] = useState<'single'>('single'); const reactFlowInstance = useReactFlow(); @@ -208,32 +208,33 @@ const VariableAssignmentEditor = () => { } // Reset index state if variable type changed to non-array - if (varInstance.type !== 'array' && leftSideIndexString) { - setLeftSideIndexString(null); + if (varInstance.type !== 'array' && leftSideIndexExprElems && leftSideIndexExprElems.length > 0) { + setLeftSideIndexExprElems(null); } // Only update if the left side has changed or if the expression is null if (!expression || (expression.leftSide instanceof Variable && expression.leftSide.id !== varInstance.id)) { - // Preserve right side if expression already exists and has one - setExpression(new Expression(varInstance, expression?.rightSide || [])); + // Preserve right side and indexExpression if exists + const varClone = varInstance.clone(); + if (leftSideIndexExprElems && leftSideIndexExprElems.length > 0) { + (varClone as any).indexExpression = leftSideIndexExprElems.map(e => e.clone()); + } + setExpression(new Expression(varClone, expression?.rightSide || [])); } } else { // AssignVariable selected, but no variable chosen for its LHS, so reset expression if (expression !== null) setExpression(null); } } - }, [leftSideVariable, selectedNode, getAllVariables, expression, leftSideIndexString]); + }, [leftSideVariable, selectedNode, getAllVariables, expression, leftSideIndexExprElems]); // Update the node data when expression changes useEffect(() => { if (selectedNode?.type === 'AssignVariable' && !isInitialLoadRef.current) { - const updatedData: any = { + reactFlowInstance.updateNodeData(selectedNode.id, { expression: expression ? expression.toObject() : null, - leftSideIndex: leftSideIndexString || undefined, - }; - - reactFlowInstance.updateNodeData(selectedNode.id, updatedData); + }); } - }, [expression, leftSideIndexString, reactFlowInstance, selectedNode]); + }, [expression, reactFlowInstance, selectedNode]); // Load assignment data when the selected node changes or when its data changes (for collaboration) useEffect(() => { @@ -256,24 +257,32 @@ const VariableAssignmentEditor = () => { const allVariables = getAllVariables(); try { - const varId = selectedNode.data.expression.leftSide.id; - // Check if the variable with this ID still exists - if (allVariables.some(v => v.id === varId)) { + const exprObj = selectedNode.data.expression; + const exprLoaded = Expression.fromObject(exprObj); + + // Verify variable still exists + if (exprLoaded.leftSide instanceof Variable) { + const varId = exprLoaded.leftSide.id; + if (!allVariables.some(v => v.id === varId)) throw new Error("Variable no longer exists"); setLeftSideVariable(varId); - // Load stored leftSideIndex if present - if (selectedNode.data.leftSideIndex) { - setLeftSideIndexString(selectedNode.data.leftSideIndex as string); + + if (exprLoaded.leftSide.indexExpression && exprLoaded.leftSide.indexExpression.length > 0) { + const clonedElems = exprLoaded.leftSide.indexExpression.map(e => e.clone()); + setLeftSideIndexExprElems(clonedElems); } else { - setLeftSideIndexString(null); + setLeftSideIndexExprElems(null); } - // Try to recreate the expression - const leftVar = allVariables.find(v => v.id === varId); - if (leftVar) { - const rightSide = selectedNode.data.expression.rightSide?.map((elem: any) => - ExpressionElement.fromObject(elem) - ) || []; - setExpression(new Expression(leftVar, rightSide)); + + // Replace leftSide reference with current variable definition + keep indexExpression + const currentVar = allVariables.find(v => v.id === varId)?.clone(); + if (currentVar) { + if (exprLoaded.leftSide.indexExpression && currentVar.type === 'array') { + currentVar.indexExpression = exprLoaded.leftSide.indexExpression.map(e => e.clone()); + } + exprLoaded.leftSide = currentVar; } + + setExpression(exprLoaded); } } catch (error) { console.error('Error creating expression:', error); @@ -282,19 +291,46 @@ const VariableAssignmentEditor = () => { // Only reset form state when it's a new node (not when collaborative data is cleared) setLeftSideVariable(''); setExpression(null); - setLeftSideIndexString(null); + setLeftSideIndexExprElems(null); } } } else { previousNodeIdRef.current = null; setLeftSideVariable(''); setExpression(null); - setLeftSideIndexString(null); + setLeftSideIndexExprElems(null); } isInitialLoadRef.current = false; }, [selectedNode, selectedNode?.data?.expression, getAllVariables]); + // Update indexExpression whenever the index string changes. + useEffect(() => { + if (!leftSideVariable) return; + + const selectedVar = getAllVariables().find(v => v.id === leftSideVariable); + if (!selectedVar || selectedVar.type !== 'array') return; + + const idxExprElems = leftSideIndexExprElems ? leftSideIndexExprElems.map(e => e.clone()) : undefined; + + setExpression(prev => { + if (!prev) return prev; + + if (!(prev.leftSide instanceof Variable)) return prev; + + // Only update when the index expression actually changed to avoid unnecessary re-renders + const prevSerialized = JSON.stringify(prev.leftSide.indexExpression ?? []); + const newSerialized = JSON.stringify(idxExprElems ?? []); + if (prevSerialized === newSerialized) return prev; + + const newExpr = prev.clone(); + if (newExpr.leftSide instanceof Variable) { + newExpr.leftSide.indexExpression = idxExprElems; + } + return newExpr; + }); + }, [leftSideIndexExprElems, leftSideVariable]); + // Expression building functions const addExpressionElement = (element: ExpressionElement) => { if (selectedNode?.type === 'AssignVariable' && expression && !isRunning) { @@ -457,7 +493,7 @@ const VariableAssignmentEditor = () => { } // Intercept array variable drag - if (activeDraggableItem.type === 'variable' && activeDraggableItem.variable?.type === 'array') { + if (activeDraggableItem.type === 'variable' && activeDraggableItem.variable?.type === 'array' && (!activeDraggableItem.variable.indexExpression || activeDraggableItem.variable.indexExpression.length === 0)) { const overLocation = getElementLocationInfo(over.id, expression); // Check if it's a valid drop target if (overLocation && (overLocation.isMainDropArea || overLocation.isNestedDropArea || overLocation.isMainExpressionElement || overLocation.isNestedExpressionElement)) { @@ -569,11 +605,17 @@ const VariableAssignmentEditor = () => { } }; + // Helper to derive a string representation when needed (UI only) + const getIndexString = () => + leftSideIndexExprElems && leftSideIndexExprElems.length > 0 + ? new Expression(undefined, leftSideIndexExprElems).toString() + : null; + // Modify variable dropdown change handler to support array index dialog const handleLeftVariableChange = (value: string) => { if (value === '__CLEAR__') { setLeftSideVariable(''); - setLeftSideIndexString(null); + setLeftSideIndexExprElems(null); return; } @@ -586,28 +628,17 @@ const VariableAssignmentEditor = () => { // Open index dialog immediately setArrayVariableForIndex(variable); setIsEditingLeftSideIndex(true); - // Prefill if already had an index - if (leftSideIndexString) { - const parsed = parseArrayAccess(`${variable.name}[${leftSideIndexString}]`); - if (parsed) { - if (parsed.isRange && parsed.rangeStart && parsed.rangeEnd) { - setInitialTab('range'); - setInitialRangeStart(parseExpressionString(parsed.rangeStart, getAllVariables())); - setInitialRangeEnd(parseExpressionString(parsed.rangeEnd, getAllVariables())); - setInitialExpression(null); - } else if (parsed.indexExpression) { - setInitialTab('single'); - setInitialExpression(parseExpressionString(parsed.indexExpression, getAllVariables())); - setInitialRangeStart(null); - setInitialRangeEnd(null); - } - } + // Prefill dialog with existing index if any + if (leftSideIndexExprElems && leftSideIndexExprElems.length > 0) { + setInitialTab('single'); + setInitialExpression(new Expression(undefined, leftSideIndexExprElems.map(e => e.clone()))); } else { - setLeftSideIndexString(null); + setInitialTab('single'); + setInitialExpression(new Expression(undefined, [])); } setIsIndexDialogOpen(true); } else { - setLeftSideIndexString(null); + setLeftSideIndexExprElems(null); } }; @@ -617,64 +648,26 @@ const VariableAssignmentEditor = () => { if (expr.isEmpty()) { // Treat as cancel – clear variable selection setLeftSideVariable(''); - setLeftSideIndexString(null); + setLeftSideIndexExprElems(null); } else { - setLeftSideIndexString(expr.toString()); + setLeftSideIndexExprElems(expr.rightSide.map(e => e.clone())); } setIsEditingLeftSideIndex(false); setIsIndexDialogOpen(false); return; } if (arrayVariableForIndex) { - const exprStr = expr.toString(); - const newValue = `${arrayVariableForIndex.name}[${exprStr}]`; - - if (editingElement) { - // Replace existing element - setExpression(prev => { - if (!prev) return null; - const newExpr = prev.clone(); - const elementToUpdate = newExpr.findElement(editingElement.id); - if (elementToUpdate) { - elementToUpdate.value = newValue; - } - return newExpr; - }); - } else { - // Add new element - const element = new ExpressionElement(crypto.randomUUID(), 'literal', newValue); - setExpression(prev => { - if (!prev) return null; - const newExpr = prev.clone(); - newExpr.addElement(element); - return newExpr; - }); - } - } - - // Reset state - setEditingElement(null); - setInitialExpression(null); - setInitialRangeStart(null); - setInitialRangeEnd(null); - setIsIndexDialogOpen(false); - }; - - // Handle dialog submission for range editing - const handleDialogSubmitRange = (_: 'range', start: Expression, end: Expression) => { - if (isEditingLeftSideIndex && arrayVariableForIndex) { - if (start.isEmpty() || end.isEmpty()) { - setLeftSideVariable(''); - setLeftSideIndexString(null); - } else { - setLeftSideIndexString(`${start.toString()}:${end.toString()}`); + if (expr.isEmpty()) { + // Ignore empty index + setEditingElement(null); + setIsIndexDialogOpen(false); + setArrayVariableForIndex(null); + return; } - setIsEditingLeftSideIndex(false); - setIsIndexDialogOpen(false); - return; - } - if (arrayVariableForIndex) { - const newValue = `${arrayVariableForIndex.name}[${start.toString()}:${end.toString()}]`; + const elemVar = arrayVariableForIndex as Variable; + const idxExpr = expr.rightSide.map(e => e.clone()); + const variableClone = elemVar.clone(); + variableClone.indexExpression = idxExpr; if (editingElement) { // Replace existing element @@ -683,13 +676,14 @@ const VariableAssignmentEditor = () => { const newExpr = prev.clone(); const elementToUpdate = newExpr.findElement(editingElement.id); if (elementToUpdate) { - elementToUpdate.value = newValue; + elementToUpdate.type = 'variable'; + elementToUpdate.setVariable(variableClone); } return newExpr; }); } else { // Add new element - const element = new ExpressionElement(crypto.randomUUID(), 'literal', newValue); + const element = new ExpressionElement(crypto.randomUUID(), 'variable', '', variableClone); setExpression(prev => { if (!prev) return null; const newExpr = prev.clone(); @@ -702,8 +696,6 @@ const VariableAssignmentEditor = () => { // Reset state setEditingElement(null); setInitialExpression(null); - setInitialRangeStart(null); - setInitialRangeEnd(null); setIsIndexDialogOpen(false); }; @@ -711,11 +703,9 @@ const VariableAssignmentEditor = () => { const handleDialogCancel = () => { setEditingElement(null); setInitialExpression(null); - setInitialRangeStart(null); - setInitialRangeEnd(null); setIsIndexDialogOpen(false); - if (isEditingLeftSideIndex && !leftSideIndexString) { + if (isEditingLeftSideIndex && leftSideIndexExprElems && leftSideIndexExprElems.length === 0) { // User canceled before providing an index -> deselect variable setLeftSideVariable(''); setIsEditingLeftSideIndex(false); @@ -725,28 +715,17 @@ const VariableAssignmentEditor = () => { // Handler to edit existing array access elements on RHS (dragged literals) const handleEditArrayAccess = (element: ExpressionElement) => { - if (!element.value.includes('[') || !element.value.includes(']')) return; + if (!element.variable || !(element.variable instanceof Variable)) return; + if (element.variable.type !== 'array' || !element.variable.indexExpression || element.variable.indexExpression.length === 0) return; - const parsed = parseArrayAccess(element.value); - if (!parsed) return; - - const arrayVar = getAllVariables().find(v => v.name === parsed.arrayName && v.type === 'array'); - if (!arrayVar) return; + const elemVar = element.variable as Variable; + const arrayVar = getAllVariables().find(v => v.id === elemVar.id && v.type === 'array') || elemVar; setEditingElement(element); setArrayVariableForIndex(arrayVar); - if (parsed.isRange && parsed.rangeStart && parsed.rangeEnd) { - setInitialTab('range'); - setInitialRangeStart(parseExpressionString(parsed.rangeStart, getAllVariables())); - setInitialRangeEnd(parseExpressionString(parsed.rangeEnd, getAllVariables())); - setInitialExpression(null); - } else if (parsed.indexExpression) { - setInitialTab('single'); - setInitialExpression(parseExpressionString(parsed.indexExpression, getAllVariables())); - setInitialRangeStart(null); - setInitialRangeEnd(null); - } + setInitialTab('single'); + setInitialExpression(new Expression(undefined, elemVar.indexExpression!.map(e => e.clone()))); setIsIndexDialogOpen(true); }; @@ -763,10 +742,10 @@ const VariableAssignmentEditor = () => { variableName={arrayVariableForIndex?.name || ''} onCancel={handleDialogCancel} onSubmit={handleDialogSubmit} - onSubmitRange={handleDialogSubmitRange} + onSubmitRange={undefined as any} initialExpression={initialExpression || undefined} - initialRangeStart={initialRangeStart || undefined} - initialRangeEnd={initialRangeEnd || undefined} + initialRangeStart={undefined as any} + initialRangeEnd={undefined as any} initialTab={initialTab} /> { setArrayVariableForIndex(selectedVar); setIsEditingLeftSideIndex(true); // Prefill dialog with existing index if any - if (leftSideIndexString) { - const parsed = parseArrayAccess(`${selectedVar.name}[${leftSideIndexString}]`); - if (parsed) { - if (parsed.isRange && parsed.rangeStart && parsed.rangeEnd) { - setInitialTab('range'); - setInitialRangeStart(parseExpressionString(parsed.rangeStart, getAllVariables())); - setInitialRangeEnd(parseExpressionString(parsed.rangeEnd, getAllVariables())); - setInitialExpression(null); - } else if (parsed.indexExpression) { - setInitialTab('single'); - setInitialExpression(parseExpressionString(parsed.indexExpression, getAllVariables())); - setInitialRangeStart(null); - setInitialRangeEnd(null); - } - } + if (leftSideIndexExprElems && leftSideIndexExprElems.length > 0) { + setInitialTab('single'); + setInitialExpression(new Expression(undefined, leftSideIndexExprElems.map(e => e.clone()))); } else { setInitialTab('single'); setInitialExpression(new Expression(undefined, [])); - setInitialRangeStart(null); - setInitialRangeEnd(null); } setIsIndexDialogOpen(true); }} > - {leftSideIndexString ? `[${leftSideIndexString}]` : '[]'} + {getIndexString() ? `[${getIndexString()}]` : '[]'} ); } @@ -879,18 +844,19 @@ const VariableAssignmentEditor = () => { element={element} removeExpressionElement={removeExpressionElement} disabled={isRunning} + onEdit={handleEditArrayAccess} /> ); } return ( - + ); })} diff --git a/flowming/src/components/Panel/Tabs/editors/VariableDeclarationEditor.tsx b/flowming/src/components/Panel/Tabs/editors/VariableDeclarationEditor.tsx index f7b57ba..45a5505 100644 --- a/flowming/src/components/Panel/Tabs/editors/VariableDeclarationEditor.tsx +++ b/flowming/src/components/Panel/Tabs/editors/VariableDeclarationEditor.tsx @@ -22,6 +22,7 @@ const VariableDeclarationEditor = () => { const [variables, setVariables] = useState([]); const [arraySizeInputs, setArraySizeInputs] = useState>({}); const [arraySizeErrors, setArraySizeErrors] = useState>({}); + const [nameErrors, setNameErrors] = useState>({}); const isInitialLoadRef = useRef(true); const updateTimeoutRef = useRef(null); const previousNodeIdRef = useRef(null); @@ -138,6 +139,15 @@ const VariableDeclarationEditor = () => { updates.arraySize = v.arraySize !== undefined && v.arraySize > 0 ? v.arraySize : 10; } + if (field === 'name') { + const newName = String(value); + const isValid = Variable.isValidName(newName); + setNameErrors(prevErrs => ({ ...prevErrs, [id]: !isValid })); + if (!isValid) { + return v; // Skip invalid update + } + } + return v.update(updates); } return v; @@ -163,40 +173,51 @@ const VariableDeclarationEditor = () => { {variables.map((variable) => (
-
- - - updateVariable(variable.id, 'name', e.target.value)} - placeholder="Variable name" - className="flex-1" - disabled={isRunning} - /> - - {variables.length > 1 && ( - + )} +
+ + {nameErrors[variable.id] && ( +
+ + + + Variable names cannot contain square brackets [ ] +
)}
diff --git a/flowming/src/components/Panel/Tabs/editors/shared/DragAndDropComponents.tsx b/flowming/src/components/Panel/Tabs/editors/shared/DragAndDropComponents.tsx index 54bb96e..d8d2a5d 100644 --- a/flowming/src/components/Panel/Tabs/editors/shared/DragAndDropComponents.tsx +++ b/flowming/src/components/Panel/Tabs/editors/shared/DragAndDropComponents.tsx @@ -35,11 +35,12 @@ export const DraggableExpressionElement = ({ element, removeExpressionElement, d const bgColor = element.type === 'variable' ? 'bg-blue-100' : element.type === 'operator' ? 'bg-red-100' : - element.type === 'literal' && element.value.includes('[') && element.value.includes(']') ? 'bg-blue-100' : element.type === 'literal' ? 'bg-green-100' : 'bg-purple-100'; // Check if this is an array access element that can be edited - const isArrayAccess = element.type === 'literal' && element.value.includes('[') && element.value.includes(']'); + const isArrayAccess = ( + element.type === 'variable' && !!element.variable?.indexExpression && element.variable.indexExpression.length > 0 + ); const handleClick = (e: React.MouseEvent) => { if (isArrayAccess && onEdit && !disabled) { diff --git a/flowming/src/components/ui/ArrayIndexDialog.tsx b/flowming/src/components/ui/ArrayIndexDialog.tsx index 7afaa4a..0b0b8b4 100644 --- a/flowming/src/components/ui/ArrayIndexDialog.tsx +++ b/flowming/src/components/ui/ArrayIndexDialog.tsx @@ -1,5 +1,6 @@ import React, { useState } from 'react'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; import { Button } from '@/components/ui/button'; import { Label } from '@/components/ui/label'; @@ -50,7 +51,7 @@ interface ArrayIndexDialogProps { initialExpression?: Expression; initialRangeStart?: Expression; initialRangeEnd?: Expression; - initialTab?: 'single' | 'range'; + initialTab?: 'single' | 'range'; // NOTE: Only single index is supported for now, although there is some implementation for range (needs to consider operations between arrays, as a range results in an array) } const ArrayIndexDialog: React.FC = ({ @@ -65,7 +66,7 @@ const ArrayIndexDialog: React.FC = ({ initialTab = 'single' }) => { const { getAllVariables } = useVariables(); - const [tab, setTab] = useState<'single' | 'range'>(initialTab); + const [tab, setTab] = useState<'single' | 'range'>('single'); // Force single-index mode const [singleExpr, setSingleExpr] = useState(initialExpression || new Expression(undefined, [])); const [rangeStart, setRangeStart] = useState(initialRangeStart || new Expression(undefined, [])); @@ -75,10 +76,13 @@ const ArrayIndexDialog: React.FC = ({ const [activeItem, setActiveItem] = useState(null); const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 8 } })); + // State for left palette tabs (Variables, Literals, Functions) + const [paletteTab, setPaletteTab] = useState<'variables' | 'literals' | 'functions'>('variables'); + // Reset expressions when dialog opens or when initial values change React.useEffect(() => { if (open) { - setTab(initialTab); + setTab('single'); // Always reset to single-index mode setSingleExpr(initialExpression || new Expression(undefined, [])); setRangeStart(initialRangeStart || new Expression(undefined, [])); setRangeEnd(initialRangeEnd || new Expression(undefined, [])); @@ -454,19 +458,176 @@ const ArrayIndexDialog: React.FC = ({ setTab(value as 'single' | 'range')}> - + Single Position - Range + Range - handleAddExpressionElement('single-index-drop', type, value)} - removeExpressionElement={(id) => handleRemoveExpressionElement('single-index-drop', id)} - dropAreaId="single-index-drop" - excludeVariables={(v: Variable) => v.type !== 'array'} - /> +
+ {/* Expression display area */} + + + Index Expression + + +
+ handleAddExpressionElement('single-index-drop', type, value)} + removeExpressionElement={(id) => handleRemoveExpressionElement('single-index-drop', id)} + dropAreaId="single-index-drop" + excludeVariables={(v: Variable) => v.type !== 'array'} + showPalette={false} + /> +
+
+
+ + {/* 2-column palette layout */} +
+ {/* Left column: Variables, Literals, Functions */} + + + setPaletteTab(val as 'variables' | 'literals' | 'functions')} className="w-full"> + + Variables + Literals + Functions + + + + + setPaletteTab(val as 'variables' | 'literals' | 'functions')}> + {/* Variables */} + +
+ {getAllVariables().filter(v => v.type !== 'array').map(variable => ( + handleAddExpressionElement('single-index-drop', 'variable', variable.id)} + /> + ))} + {getAllVariables().filter(v => v.type !== 'array').length === 0 && ( +
No variables available
+ )} +
+
+ + {/* Literals */} + +
+ {/* Boolean literals */} +
+ +
+ {['true', 'false'].map(val => ( + handleAddExpressionElement('single-index-drop', 'literal', val)} + /> + ))} +
+
+ + {/* Integer literal */} +
+ +
+ + +
+
+ + {/* String literal */} +
+ +
+ + +
+
+ + {/* Float literal */} +
+ +
+ + +
+
+
+
+ + {/* Functions */} + +
+ {['integer', 'string', 'float', 'boolean'].map(func => ( + handleAddExpressionElement('single-index-drop', 'function', func)} + /> + ))} +
+
+
+
+
+ + {/* Right column: Operators */} + + + Operators + + + {operators.map(op => ( + handleAddExpressionElement('single-index-drop', 'operator', op)} + /> + ))} + + +
+
@@ -631,7 +792,6 @@ const ArrayIndexDialog: React.FC = ({
{activeItem.value}
diff --git a/flowming/src/components/ui/InputDialog.tsx b/flowming/src/components/ui/InputDialog.tsx index 3d6d18c..bf5f68e 100644 --- a/flowming/src/components/ui/InputDialog.tsx +++ b/flowming/src/components/ui/InputDialog.tsx @@ -34,9 +34,17 @@ export function InputDialog({ onCancel, }: InputDialogProps) { const [value, setValue] = useState(''); + const [error, setError] = useState(null); const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); + // Validation: disallow empty value except for string type + if (variableType !== 'string' && value.trim() === '') { + setError('Please enter a value.'); + return; + } + + setError(null); onSubmit(value); setValue(''); }; @@ -72,7 +80,7 @@ export function InputDialog({ type="number" step="1" value={value} - onChange={(e) => setValue(e.target.value)} + onChange={(e) => { setValue(e.target.value); if(error) setError(null); }} placeholder={placeholder || "Enter an integer"} className="w-full h-11 text-base" autoFocus @@ -86,7 +94,7 @@ export function InputDialog({ type="number" step="any" value={value} - onChange={(e) => setValue(e.target.value)} + onChange={(e) => { setValue(e.target.value); if(error) setError(null); }} placeholder={placeholder || "Enter a decimal number"} className="w-full h-11 text-base" autoFocus @@ -100,7 +108,7 @@ export function InputDialog({ id="input-value" type="text" value={value} - onChange={(e) => setValue(e.target.value)} + onChange={(e) => { setValue(e.target.value); if(error) setError(null); }} placeholder={placeholder || "Enter text"} className="w-full h-11 text-base" autoFocus @@ -132,6 +140,14 @@ export function InputDialog({ Value ({variableType}) {renderInput()} + {error && ( +
+ + + + {error} +
+ )} diff --git a/flowming/src/models/Expression.ts b/flowming/src/models/Expression.ts index a7b4064..c055d67 100644 --- a/flowming/src/models/Expression.ts +++ b/flowming/src/models/Expression.ts @@ -1,8 +1,8 @@ -import { Variable, VariableType, ValueTypeMap } from './Variable'; +import { Variable, VariableType, ValueTypeMap, ArraySubtype } from './Variable'; import { ExpressionElement } from './ExpressionElement'; import { ValuedVariable } from './ValuedVariable'; import { buildAST } from './ExpressionParser'; -import { IVariable } from './Variable'; +import type { IVariable } from './IVariable'; export const operators = ['+', '-', '*', '/', '!', '%', '&&', '||', '==', '!=', '>', '<', '>=', '<=', '(', ')']; export type IOperator = typeof operators[number]; @@ -46,13 +46,19 @@ interface BinaryOpNode extends BaseASTNode { right: ExpressionASTNode; } +interface MemberAccessNode extends BaseASTNode { + type: 'MemberAccess'; + object: IdentifierNode; + property: ExpressionASTNode; +} + interface FunctionCallNode extends BaseASTNode { type: 'FunctionCall'; functionName: 'integer' | 'string' | 'float' | 'boolean'; argument: ExpressionASTNode; } -type ExpressionASTNode = LiteralNode | IdentifierNode | UnaryOpNode | BinaryOpNode | FunctionCallNode; +type ExpressionASTNode = LiteralNode | IdentifierNode | UnaryOpNode | BinaryOpNode | FunctionCallNode | MemberAccessNode; export class Expression implements IExpression { @@ -60,7 +66,11 @@ export class Expression implements IExpression { rightSide: ExpressionElement[]; equality?: IEquality; - constructor(leftSide: Variable | ExpressionElement[] | undefined, rightSide: ExpressionElement[] = [], equality?: IEquality) { + constructor( + leftSide: Variable | ExpressionElement[] | undefined, + rightSide: ExpressionElement[] = [], + equality?: IEquality + ) { if(leftSide != undefined){ if (equality && (!Array.isArray(leftSide) || leftSide.some(e => !(e instanceof ExpressionElement)))) { throw new Error('leftSide must be an array of ExpressionElement instances'); @@ -96,7 +106,8 @@ export class Expression implements IExpression { calculateValue( exprElements: ExpressionElement[], exprTyping: VariableType | null, - currentValuedVariables: ValuedVariable[] + currentValuedVariables: ValuedVariable[], + arraySubtype?: ArraySubtype ): ValueTypeMap[VariableType] { try { if (exprElements.length === 0) { @@ -123,6 +134,14 @@ export class Expression implements IExpression { if (exprTyping === 'integer' && result.type === 'float') { return Math.floor(Number(result.value)); } + + if (exprTyping === 'array') { + // Make sure that the result is the correct type for the array subtype + if (result.type !== arraySubtype) { + throw new Error(`Type mismatch: Cannot assign a value of type '${result.type}' to a variable of type '${arraySubtype}'.`); + } + return result.value; + } throw new Error(`Type mismatch: Cannot assign a value of type '${result.type}' to a variable of type '${exprTyping}'.`); } @@ -147,6 +166,37 @@ export class Expression implements IExpression { case 'Identifier': const v = currentValuedVariables.find(vv => vv.id === node.variable.id); if (!v) throw new Error(`Variable "${node.name}" does not have a value assigned.`); + + // If the identifier represents an array element (indexExpression present), evaluate the index and return that element + if (node.variable.type === 'array' && node.variable.indexExpression && node.variable.indexExpression.length > 0) { + let idx: number; + try { + idx = this.calculateValue( + node.variable.indexExpression as any, + 'integer', + currentValuedVariables + ) as number; + } catch (err: any) { + const msg = err?.message ?? ''; + if (msg.includes('Type mismatch')) { + throw new Error(`Array index for "${node.name}" must be an integer.`); + } + // Propagate all other errors untouched + throw err; + } + if (!Number.isInteger(idx)) { // This should never happen, as the index expression is already validated to be an integer in calculateValue + throw new Error(`Array index for "${node.name}" must be an integer.`); + } + + const arr = Array.isArray(v.value) ? v.value : []; + const size = v.arraySize ?? arr.length; + if (idx < 0 || idx >= size) { + throw new Error(`Array index ${idx} is out of bounds for "${node.name}" (size ${size}).`); + } + const subtype = v.arraySubtype!; + return { value: arr[idx], type: subtype }; + } + return { value: v.value, type: v.type }; case 'FunctionCall': @@ -281,10 +331,124 @@ export class Expression implements IExpression { * and the calculated value from the expression and the current values for variables */ assignValue(currentValuedVariables: ValuedVariable[]): ValuedVariable { - if (!(this.leftSide instanceof Variable)) throw new Error('leftSide must be a Variable instance'); + // NOTE: operations between arrays will be always single values, as you must always specify an index for the array + // This means that operations like concatenation and so on are not allowed between whole arrays + // So the return value will always be a single value (and we only care for type checking for the subtype) - const value = this.calculateValue(this.rightSide, this.leftSide.type, currentValuedVariables); - return new ValuedVariable(this.leftSide.id, this.leftSide.type, this.leftSide.name, this.leftSide.nodeId, value); + // NOTE: calculateValue for array element returns the whole array with the corresponding index modified + // the variable value holds the whole array (for debugging purposes and future implementations) + + if (this.leftSide instanceof Variable) { + // Array variable assignment requires indexExpression stored in Variable + if (this.leftSide.type === 'array') { + if (!this.leftSide.indexExpression || this.leftSide.indexExpression.length === 0) { + throw new Error(`Cannot assign directly to array variable "${this.leftSide.name}". Use array index access instead (e.g., ${this.leftSide.name}[index] = value).`); + } + return this.#assignArrayValue(this.leftSide, this.leftSide.indexExpression, currentValuedVariables); + } + + const value = this.calculateValue(this.rightSide, this.leftSide.type, currentValuedVariables, this.leftSide.arraySubtype); + return new ValuedVariable( + this.leftSide.id, + this.leftSide.type, + this.leftSide.name, + this.leftSide.nodeId, + value as Exclude, // Type is already validated as non-array above + this.leftSide.arraySubtype, + this.leftSide.arraySize + ); + } else { + throw new Error('leftSide must be a Variable instance'); + } + } + + /** + * Handles array index assignments like arr[i-2] = value + * + * Note: This method performs complex AST operations which can take significant time (non-trivial). + * (this is not a problem for now) + * + * @param arrayVarDef The array variable definition + * @param indexExprElements The array access expression elements + * @param currentValuedVariables The current state of variables + * @returns The updated ValuedVariable with the modified array + */ + #assignArrayValue(arrayVarDef: Variable, indexExprElements: ExpressionElement[], currentValuedVariables: ValuedVariable[]): ValuedVariable { + try { + // Construct tokens: Variable token, '[' token, ...indexExprElements, ']' token + const arrayVarElement = new ExpressionElement(crypto.randomUUID(), 'variable', arrayVarDef.name, arrayVarDef); + const openBracket = new ExpressionElement(crypto.randomUUID(), 'operator', '['); + const closeBracket = new ExpressionElement(crypto.randomUUID(), 'operator', ']'); + + const tokens: ExpressionElement[] = [arrayVarElement, openBracket, ...indexExprElements, closeBracket]; + const leftAST = buildAST(tokens); + + if (leftAST.type !== 'MemberAccess') { + throw new Error('Left side of array assignment must be an array access expression (e.g., arr[index])'); + } + + const memberAccess = leftAST as MemberAccessNode; + + // Find (or lazily initialize) the target array variable in the current valued variables set + let arrayVar = currentValuedVariables.find(vv => vv.id === memberAccess.object.variable.id); + + // If the array variable has not been initialized yet (i.e., it has been declared but no + // valued-variable instance exists in the current execution scope), create it on the fly so + // that the assignment can proceed. This situation can arise when the DeclareVariable node + // has executed but, due to asynchronous propagation of node data, the valued variable did + // not reach this node before the assignment executes. + if (!arrayVar) { + arrayVar = ValuedVariable.fromVariable(memberAccess.object.variable, null); + currentValuedVariables.push(arrayVar); + } + + // Validate array structure and constraints + if (!Array.isArray(arrayVar.value)) { + throw new Error(`Variable "${memberAccess.object.name}" is not a valid array.`); + } + + const subtype = arrayVar.arraySubtype; + const arraySize = arrayVar.arraySize; + if (!subtype) throw new Error(`Array "${memberAccess.object.name}" does not have a defined subtype.`); + if (!arraySize || arraySize < 1) throw new Error(`Array "${memberAccess.object.name}" does not have a valid size defined.`); + + // Enforce fixed-length constraint + if (arrayVar.value.length !== arraySize) { + throw new Error(`Array "${memberAccess.object.name}" has incorrect length: expected ${arraySize}, but got ${arrayVar.value.length}.`); + } + + // Evaluate the index expression + const indexResult = this.#evaluateAST(memberAccess.property, currentValuedVariables); + if (indexResult.type !== 'integer') { + throw new Error(`Array index for "${memberAccess.object.name}" must be an integer, but got ${indexResult.type}.`); + } + const index = indexResult.value; + + // Bounds checking + if (index < 0 || index >= arraySize) { + throw new Error(`Array index ${index} is out of bounds for "${memberAccess.object.name}" (fixed size ${arraySize}).`); + } + + // Evaluate the right side to get the new value + const newValue = this.calculateValue(this.rightSide, subtype, currentValuedVariables); + + // Create a copy of the array and update the specified index + const updatedArray = [...arrayVar.value]; + updatedArray[index] = newValue; + + // Return the updated ValuedVariable with the modified array + return new ValuedVariable( + arrayVar.id, + arrayVar.type, + arrayVar.name, + arrayVar.nodeId, + updatedArray, + arrayVar.arraySubtype, + arrayVar.arraySize + ); + } catch (e) { + throw e; + } } /** @@ -411,20 +575,6 @@ export class Expression implements IExpression { return this; } - /** - * Inserts an element at a specific position - */ - insertElementAt(element: ExpressionElement, index: number): void { - this.rightSide.splice(index, 0, element); - } - - /** - * Updates the variable in the leftSide of the expression - */ - updateLeftSide(variable: Variable): void { - this.leftSide = variable; - } - /** * Updates the expression with new values */ @@ -441,16 +591,40 @@ export class Expression implements IExpression { * Updates the variables in the expression */ updateVariables(variables: Variable[]): void { + // Helper to update ExpressionElement lists based on current variable set, removing missing vars + const processElements = (elements: ExpressionElement[]): ExpressionElement[] => { + return elements.flatMap(elem => { + if (elem.type !== 'variable') return [elem]; + const vMatch = variables.find(vv => vv.id === elem.variable?.id); + if (!vMatch) return []; // Variable deleted -> remove element + const elemVarClone = vMatch.clone(); + if (elem.variable?.indexExpression && elemVarClone.type === 'array') { + elemVarClone.indexExpression = processElements(elem.variable.indexExpression as ExpressionElement[]); + } + if (elem.variable?.indexExpression && (!elemVarClone.indexExpression || elemVarClone.indexExpression.length === 0)) { + return []; // index expression emptied -> remove the variable element entirely + } + return [new ExpressionElement(elem.id, elem.type, elemVarClone.toString(), elemVarClone)]; + }); + }; + // Update the leftSide variable if it exists in the variable list - if(this.leftSide != undefined){ + if (this.leftSide != undefined) { if (this.leftSide instanceof Variable) { + const existingIndexExpr = this.leftSide.indexExpression; const leftSideVariable = variables.find(v => v.id === (this.leftSide as Variable).id); if (!leftSideVariable) { - // If no left side variable is found, expression is invalid - // TODO: do not unmount expression when LHS variable deselected or not found (?) - throw new Error(`Variable with id ${this.leftSide.id} not found`); + // If no left side variable is found, expression is invalid + // TODO: do not unmount expression when LHS variable deselected or not found (?) + throw new Error(`Variable with id ${this.leftSide.id} not found`); + } + + // Preserve array index expression (if any) when replacing the variable reference. + const updatedVar = leftSideVariable.clone(); + if (existingIndexExpr && updatedVar.type === 'array') { + updatedVar.indexExpression = existingIndexExpr; } - this.leftSide = leftSideVariable; + this.leftSide = updatedVar; } else { this.leftSide = this.leftSide?.flatMap(e => { if (e.type !== 'variable') return [e]; @@ -458,7 +632,16 @@ export class Expression implements IExpression { // If no variable is found, delete expression element (by flattening []) if (!variable) return []; - const expressionElement = new ExpressionElement(e.id, e.type, variable.name, variable); + // Preserve indexExpression if present + const updatedVar = variable.clone(); + if (e.variable?.indexExpression && updatedVar.type === 'array') { + updatedVar.indexExpression = processElements(e.variable.indexExpression as ExpressionElement[]); + } + if (e.variable?.indexExpression && (!updatedVar.indexExpression || updatedVar.indexExpression.length === 0)) { + // index became empty -> remove entire variable element + return []; + } + const expressionElement = new ExpressionElement(e.id, e.type, updatedVar.toString(), updatedVar); return [expressionElement]; }); } @@ -470,7 +653,16 @@ export class Expression implements IExpression { // If no variable is found, delete expression element (by flattening []) if (!variable) return []; - const expressionElement = new ExpressionElement(e.id, e.type, variable.name, variable); + // Preserve indexExpression if present + const updatedVar = variable.clone(); + if (e.variable?.indexExpression && updatedVar.type === 'array') { + updatedVar.indexExpression = processElements(e.variable.indexExpression as ExpressionElement[]); + } + if (e.variable?.indexExpression && (!updatedVar.indexExpression || updatedVar.indexExpression.length === 0)) { + // index became empty -> remove entire variable element + return []; + } + const expressionElement = new ExpressionElement(e.id, e.type, updatedVar.toString(), updatedVar); return [expressionElement]; }); } @@ -481,9 +673,11 @@ export class Expression implements IExpression { isEmpty(): boolean { if(this.leftSide != undefined){ if(this.equality) { + // For conditionals, leftSide is ExpressionElement[] return (this.leftSide as ExpressionElement[]).length === 0 && this.rightSide.length === 0; } - return !(this.leftSide as Variable) && this.rightSide.length === 0; + // Array access assignment is ExpressionElement[] (?) + return (!(this.leftSide as Variable) || (this.leftSide as ExpressionElement[]).length === 0) && this.rightSide.length === 0; } return this.rightSide.length === 0; diff --git a/flowming/src/models/ExpressionElement.ts b/flowming/src/models/ExpressionElement.ts index b59d8b6..2cb71af 100644 --- a/flowming/src/models/ExpressionElement.ts +++ b/flowming/src/models/ExpressionElement.ts @@ -1,35 +1,28 @@ -import { IVariable, Variable } from './Variable'; +import { Variable } from './Variable'; +import type { IVariable } from './IVariable'; import { Expression } from './Expression'; +import type { IExpressionElement } from './IExpressionElement'; export type ExpressionElementType = 'variable' | 'operator' | 'literal' | 'function'; - -export interface IExpressionElement { - id: string; - type: ExpressionElementType; - value: string; - variable?: IVariable; - nestedExpression?: any; -} - export class ExpressionElement implements IExpressionElement { id: string; type: ExpressionElementType; value: string; - variable?: Variable; + variable?: IVariable; nestedExpression?: Expression; constructor( id: string, type: ExpressionElementType, value: string, - variableOrNested?: Variable | Expression + variableOrNested?: IVariable | Expression ) { this.id = id; this.type = type; this.value = value; if (type === 'variable' && variableOrNested instanceof Variable) { this.variable = variableOrNested; - this.value = variableOrNested.name; + this.value = variableOrNested.toString(); } else if (type === 'function' && variableOrNested instanceof Expression) { this.nestedExpression = variableOrNested; } @@ -38,12 +31,12 @@ export class ExpressionElement implements IExpressionElement { /** * Sets the associated variable for this element */ - setVariable(variable: Variable): void { + setVariable(variable: IVariable): void { if (this.type !== 'variable') { throw new Error('Cannot set variable on non-variable element'); } this.variable = variable; - this.value = variable.name; + this.value = variable.toString(); } /** @@ -95,7 +88,7 @@ export class ExpressionElement implements IExpressionElement { this.id, this.type, this.value, - this.type === 'variable' ? this.variable : this.type === 'function' ? this.nestedExpression?.clone() : undefined + this.type === 'variable' ? this.variable?.clone() : this.type === 'function' ? this.nestedExpression?.clone() : undefined ); if (this.type === 'function' && this.nestedExpression) { cloned.nestedExpression = this.nestedExpression.clone(); @@ -106,12 +99,12 @@ export class ExpressionElement implements IExpressionElement { /** * Creates an object representation of the expression element */ - toObject(): IExpressionElement { + toObject(): any { return { id: this.id, type: this.type, value: this.value, - variable: this.variable?.toObject(), + variable: this.variable ? this.variable.toObject() : undefined, nestedExpression: this.nestedExpression?.toObject() }; } diff --git a/flowming/src/models/ExpressionParser.ts b/flowming/src/models/ExpressionParser.ts index e2a94d3..5179171 100644 --- a/flowming/src/models/ExpressionParser.ts +++ b/flowming/src/models/ExpressionParser.ts @@ -30,13 +30,19 @@ export interface BinaryOpNode extends BaseASTNode { right: ExpressionASTNode; } +export interface MemberAccessNode extends BaseASTNode { + type: 'MemberAccess'; + object: IdentifierNode; + property: ExpressionASTNode; +} + export interface FunctionCallNode extends BaseASTNode { type: 'FunctionCall'; functionName: 'integer' | 'string' | 'float' | 'boolean'; argument: ExpressionASTNode; } -export type ExpressionASTNode = LiteralNode | IdentifierNode | UnaryOpNode | BinaryOpNode | FunctionCallNode; +export type ExpressionASTNode = LiteralNode | IdentifierNode | UnaryOpNode | BinaryOpNode | FunctionCallNode | MemberAccessNode; // Parser Implementation export function buildAST(elements: ExpressionElement[]): ExpressionASTNode { @@ -161,8 +167,20 @@ export function buildAST(elements: ExpressionElement[]): ExpressionASTNode { return parseLiteral(tok.value); } if (tok.isVariable() && tok.variable) { - next(); - return { type: 'Identifier', name: tok.variable.name, variable: tok.variable }; + const idNode: IdentifierNode = { type: 'Identifier', name: tok.variable.name, variable: tok.variable }; + next(); // Consume identifier token + + if (peek()?.value === '[') { + if (idNode.variable.type !== 'array') { + throw new Error(`Variable "${idNode.name}" is not an array and cannot be indexed.`); + } + next(); // Consume '[' + const indexExpr = parseEquality(); + expectOperator(']'); + return { type: 'MemberAccess', object: idNode, property: indexExpr }; + } + + return idNode; } if (tok.isFunction()) { const fnTok = next(); diff --git a/flowming/src/models/IExpressionElement.ts b/flowming/src/models/IExpressionElement.ts new file mode 100644 index 0000000..6fa46c2 --- /dev/null +++ b/flowming/src/models/IExpressionElement.ts @@ -0,0 +1,19 @@ +import type { IVariable, ExpressionElementType } from '.'; + +export interface IExpressionElement { + id: string; + type: ExpressionElementType; + value: string; + variable?: IVariable; + nestedExpression?: any; + + // Methods + setVariable(variable: IVariable): void; + toString(): string; + isVariable(): boolean; + isOperator(): boolean; + isLiteral(): boolean; + isFunction(): boolean; + clone(): IExpressionElement; + toObject(): any; +} \ No newline at end of file diff --git a/flowming/src/models/IVariable.ts b/flowming/src/models/IVariable.ts new file mode 100644 index 0000000..ce7a22a --- /dev/null +++ b/flowming/src/models/IVariable.ts @@ -0,0 +1,19 @@ +import type { VariableType, ArraySubtype, IExpressionElement } from '.'; + +export interface IVariable { + id: string; + type: VariableType; + name: string; + nodeId: string; // ID of the node that declared this variable + arraySubtype?: ArraySubtype; // Only applicable when type is 'array' + arraySize?: number; // Only applicable when type is 'array' + indexExpression?: IExpressionElement[]; // Only applicable when type is 'array' + + // Methods + toString(): string; + toDeclarationString(): string; + update(updates: Partial): IVariable; + clone(): IVariable; + isEqual(other: IVariable): boolean; + toObject(): any; +} \ No newline at end of file diff --git a/flowming/src/models/ValuedVariable.ts b/flowming/src/models/ValuedVariable.ts index eb06b7b..0cc1194 100644 --- a/flowming/src/models/ValuedVariable.ts +++ b/flowming/src/models/ValuedVariable.ts @@ -1,4 +1,5 @@ -import { IVariable, ValueTypeMap, Variable, VariableType, ArraySubtype } from "./Variable"; +import { ValueTypeMap, Variable, VariableType, ArraySubtype } from "./Variable"; +import { IVariable } from './IVariable'; export interface IValuedVariable extends IVariable { value: ValueTypeMap[T]; @@ -22,10 +23,18 @@ export class ValuedVariable extends Variable implements return `${this.name}: ${this.value}`; } + /** + * Clones the valued variable + */ + clone(): ValuedVariable { + const clonedValue = Array.isArray(this.value) ? [...this.value] : this.value; + return new ValuedVariable(this.id, this.type as T, this.name, this.nodeId, clonedValue as ValueTypeMap[T], this.arraySubtype, this.arraySize); + } + /** * Creates an object representation of the valued variable */ - toObject(): IValuedVariable { + toObject(): any { return { id: this.id, type: this.type, @@ -71,7 +80,7 @@ export class ValuedVariable extends Variable implements initializedValue = false as ValueTypeMap[T]; break; case 'array': - // Initialize array with default values based on subtype and size + // Array is initialized by filling size with default values based on subtype const size = variable.arraySize || 10; const subtype = variable.arraySubtype || 'integer'; let defaultValue: any; diff --git a/flowming/src/models/Variable.ts b/flowming/src/models/Variable.ts index 11f7130..7b0b2b3 100644 --- a/flowming/src/models/Variable.ts +++ b/flowming/src/models/Variable.ts @@ -1,3 +1,7 @@ +import type { IVariable } from './IVariable'; +import { ExpressionElement } from './ExpressionElement'; +import type { IExpressionElement } from './IExpressionElement'; + // Available variable types export const variableTypes = [ 'integer', @@ -27,16 +31,6 @@ export type ValueTypeMap = { array: any[]; }; -// Define the variable structure -export interface IVariable { - id: string; - type: VariableType; - name: string; - nodeId: string; // ID of the node that declared this variable - arraySubtype?: ArraySubtype; // Only applicable when type is 'array' - arraySize?: number; // Only applicable when type is 'array' -} - export class Variable implements IVariable { id: string; type: VariableType; @@ -44,17 +38,18 @@ export class Variable implements IVariable { nodeId: string; arraySubtype?: ArraySubtype; arraySize?: number; + indexExpression?: IExpressionElement[]; - constructor(id: string, type: VariableType, name: string, nodeId: string, arraySubtype?: ArraySubtype, arraySize?: number) { + constructor(id: string, type: VariableType, name: string, nodeId: string, arraySubtype?: ArraySubtype, arraySize?: number, indexExpression?: IExpressionElement[]) { this.id = id; this.type = type; this.name = name; this.nodeId = nodeId; + this.indexExpression = indexExpression; // Ensure array metadata is always valid when the variable is an array if (type === 'array') { - // Fallback to integer subtype if none provided (should not normally happen) - this.arraySubtype = arraySubtype ?? 'integer'; + this.arraySubtype = arraySubtype; // Enforce a positive, non-zero size. Default to 1 if invalid. const validSize = arraySize !== undefined && arraySize >= 1 ? arraySize : 1; this.arraySize = validSize; @@ -68,6 +63,12 @@ export class Variable implements IVariable { * Creates a string representation of the variable */ toString(): string { + // Render array element access when an index expression exists + if (this.indexExpression && this.indexExpression.length > 0) { + // Manually join to avoid circular dependency on Expression.toString() + const idxExprStr = this.indexExpression.map(e => (e as ExpressionElement).toString()).join(' '); + return `${this.name}[${idxExprStr}]`; + } return this.name; } @@ -106,7 +107,8 @@ export class Variable implements IVariable { updates.name ?? this.name, updates.nodeId ?? this.nodeId, updates.hasOwnProperty('arraySubtype') ? updates.arraySubtype : this.arraySubtype, - updates.hasOwnProperty('arraySize') ? updates.arraySize : this.arraySize + updates.hasOwnProperty('arraySize') ? updates.arraySize : this.arraySize, + updates.hasOwnProperty('indexExpression') ? updates.indexExpression : this.indexExpression ); } @@ -114,7 +116,8 @@ export class Variable implements IVariable { * Clones the variable */ clone(): Variable { - return new Variable(this.id, this.type, this.name, this.nodeId, this.arraySubtype, this.arraySize); + const clonedIndexExpr = this.indexExpression ? this.indexExpression.map(e => e.clone()) : undefined; + return new Variable(this.id, this.type, this.name, this.nodeId, this.arraySubtype, this.arraySize, clonedIndexExpr); } /** @@ -127,14 +130,15 @@ export class Variable implements IVariable { /** * Creates an object representation of the variable */ - toObject(): IVariable { + toObject(): any { return { id: this.id, type: this.type, name: this.name, nodeId: this.nodeId, arraySubtype: this.arraySubtype, - arraySize: this.arraySize + arraySize: this.arraySize, + indexExpression: this.indexExpression ? this.indexExpression.map(e => e.toObject()) : undefined, }; } @@ -142,7 +146,17 @@ export class Variable implements IVariable { * Creates a Variable from a plain object */ static fromObject(obj: IVariable): Variable { - return new Variable(obj.id, obj.type as VariableType, obj.name, obj.nodeId, obj.arraySubtype, obj.arraySize); + const idxExpr = obj.indexExpression + ? obj.indexExpression.map(e => ExpressionElement.fromObject(e)) + : undefined; + return new Variable(obj.id, obj.type as VariableType, obj.name, obj.nodeId, obj.arraySubtype, obj.arraySize, idxExpr); + } + + /** + * Checks if a variable name is valid (no reserved characters like '[' or ']'). + */ + static isValidName(name: string): boolean { + return !(/\[|\]/.test(name)); } } diff --git a/flowming/src/models/index.ts b/flowming/src/models/index.ts index e25bc62..2f9c712 100644 --- a/flowming/src/models/index.ts +++ b/flowming/src/models/index.ts @@ -1,3 +1,6 @@ -export * from './Variable'; +export * from './Expression'; export * from './ExpressionElement'; -export * from './Expression'; \ No newline at end of file +export * from './IExpressionElement'; +export * from './Variable'; +export * from './IVariable'; +export * from './ValuedVariable'; \ No newline at end of file diff --git a/flowming/src/models/pythonAST.ts b/flowming/src/models/pythonAST.ts index 8490217..19836ba 100644 --- a/flowming/src/models/pythonAST.ts +++ b/flowming/src/models/pythonAST.ts @@ -82,6 +82,13 @@ export interface CallExpression extends Expression { arguments: Expression[]; } +// arr[index] +export interface SubscriptExpression extends Expression { + type: 'SubscriptExpression'; + object: Identifier; + index: Expression; +} + // Special node for print (Python-specific convenience) export interface PrintStatement extends Statement { type: 'PrintStatement'; diff --git a/flowming/src/tests/ArrayCodeGeneration.test.ts b/flowming/src/tests/ArrayCodeGeneration.test.ts new file mode 100644 index 0000000..7146f21 --- /dev/null +++ b/flowming/src/tests/ArrayCodeGeneration.test.ts @@ -0,0 +1,187 @@ +import { describe, it, expect } from 'vitest'; +import { generatePythonCode } from '../utils/codeGeneration'; +import { FlowNode } from '../components/Flow/FlowTypes'; +import { Edge } from '@xyflow/react'; +import { Expression } from '../models/Expression'; +import { ExpressionElement } from '../models/ExpressionElement'; +import { Variable } from '../models/Variable'; + +// Helper functions +const createNode = ( + id: string, + type: string, + data: object = {}, + position = { x: 0, y: 0 } +): FlowNode => ({ + id, + type, + position, + data: { label: `Node ${id}`, ...data }, + selected: false, + dragging: false, + width: 100, + height: 50, +}); + +const createEdge = ( + source: string, + target: string, + label?: 'yes' | 'no' +): Edge => { + const edge: Edge = { + id: `e${source}-${target}`, + source, + target, + }; + if (label) { + edge.data = { conditionalLabel: label }; + } + return edge; +}; + +const cleanCode = (code: string) => { + // Removes comments and empty lines for easier assertions + return code + .split('\n') + .filter(line => !line.trim().startsWith('#')) + .join('\n') + .trim(); +}; + +// arr : integer[5] +const arrVar = new Variable('var-arr', 'array', 'arr', 'n1', 'integer', 5); +// i : integer +const idxVar = new Variable('var-i', 'integer', 'i', 'n1'); + +const createLiteralEl = (value: any) => new ExpressionElement('lit', 'literal', String(value)); +const createVarEl = (v: Variable) => new ExpressionElement('v', 'variable', v.name, v); +const createOpEl = (op: string) => new ExpressionElement('op', 'operator', op); + +describe('codeGeneration – arrays', () => { + it('should generate an assignment to an array element arr[i] = 5', () => { + const arrIdxVar = arrVar.clone(); + arrIdxVar.indexExpression = [createVarEl(idxVar)]; + + const nodes: FlowNode[] = [ + createNode('1', 'Start'), + createNode('2', 'AssignVariable', { + expression: new Expression(arrIdxVar, [createLiteralEl(5)]).toObject(), + }), + createNode('3', 'End'), + ]; + + const edges: Edge[] = [ + createEdge('1', '2'), + createEdge('2', '3'), + ]; + + const code = generatePythonCode(nodes, edges); + expect(cleanCode(code)).toBe('arr[i] = 5'); + }); + + it('should generate input into an array element', () => { + const arrIdxVar = arrVar.clone(); + arrIdxVar.indexExpression = [createVarEl(idxVar)]; + + const nodes: FlowNode[] = [ + createNode('1', 'Start'), + createNode('2', 'Input', { variable: arrIdxVar }), + createNode('3', 'End'), + ]; + const edges: Edge[] = [createEdge('1', '2'), createEdge('2', '3')]; + + const code = generatePythonCode(nodes, edges); + const expected = "arr[i] = int(input(\"Enter the value of 'arr' by keyboard\"))"; + expect(cleanCode(code)).toBe(expected); + }); + + it('should generate output of an array element', () => { + const arrIdxVar = arrVar.clone(); + arrIdxVar.indexExpression = [createVarEl(idxVar)]; + const expr = new Expression(undefined, [createVarEl(arrIdxVar)]); + + const nodes: FlowNode[] = [ + createNode('1', 'Start'), + createNode('2', 'Output', { expression: expr.toObject() }), + createNode('3', 'End'), + ]; + const edges: Edge[] = [createEdge('1', '2'), createEdge('2', '3')]; + + const code = generatePythonCode(nodes, edges); + expect(cleanCode(code)).toBe('print(arr[i])'); + }); + + it('should generate a conditional based on an array element', () => { + const arrIdxVar = arrVar.clone(); + arrIdxVar.indexExpression = [createVarEl(idxVar)]; + const condExpr = new Expression([createVarEl(arrIdxVar)], [createLiteralEl(0)], '>'); + + const nodes: FlowNode[] = [ + createNode('1', 'Start'), + createNode('2', 'Conditional', { expression: condExpr.toObject() }), + createNode('3', 'End'), + ]; + const edges: Edge[] = [ + createEdge('1', '2'), + createEdge('2', '3', 'yes'), + createEdge('2', '3', 'no'), + ]; + + const code = generatePythonCode(nodes, edges); + expect(cleanCode(code)).toContain('if (arr[i] > 0):'); + }); + + it('should ignore DeclareVariable for array variable in generated code', () => { + const nodes: FlowNode[] = [ + createNode('1', 'Start'), + createNode('2', 'DeclareVariable', { variable: arrVar.toObject() }), + createNode('3', 'End'), + ]; + const edges: Edge[] = [createEdge('1', '2'), createEdge('2', '3')]; + + const code = generatePythonCode(nodes, edges); + expect(cleanCode(code)).toBe(''); + }); + + // Complex index expression scenarios + + it('should generate assignment with arithmetic index arr[i + 1] = 0', () => { + const arrIdxVar = arrVar.clone(); + arrIdxVar.indexExpression = [createVarEl(idxVar), createOpEl('+'), createLiteralEl(1)]; + + const nodes: FlowNode[] = [ + createNode('1', 'Start'), + createNode('2', 'AssignVariable', { + expression: new Expression(arrIdxVar, [createLiteralEl(0)]).toObject(), + }), + createNode('3', 'End'), + ]; + const edges: Edge[] = [createEdge('1', '2'), createEdge('2', '3')]; + + const code = generatePythonCode(nodes, edges); + expect(cleanCode(code)).toBe('arr[(i + 1)] = 0'); + }); + + it('should generate assignment with function and arithmetic in index', () => { + // arr[int((i * 2) - 1)] = 3 + const arrIdxVar = arrVar.clone(); + const innerExpr = new Expression(undefined, [ + createVarEl(idxVar), createOpEl('*'), createLiteralEl(2), createOpEl('-'), createLiteralEl(1) + ]); + const funcEl = new ExpressionElement('fn', 'function', 'integer', innerExpr); + arrIdxVar.indexExpression = [funcEl]; + + const nodes: FlowNode[] = [ + createNode('1', 'Start'), + createNode('2', 'AssignVariable', { + expression: new Expression(arrIdxVar, [createLiteralEl(3)]).toObject(), + }), + createNode('3', 'End'), + ]; + const edges: Edge[] = [createEdge('1', '2'), createEdge('2', '3')]; + + const code = generatePythonCode(nodes, edges); + const expected = 'arr[int(((i * 2) - 1))] = 3'; + expect(cleanCode(code)).toBe(expected); + }); +}); \ No newline at end of file diff --git a/flowming/src/tests/ArrayExpression.test.ts b/flowming/src/tests/ArrayExpression.test.ts new file mode 100644 index 0000000..c869023 --- /dev/null +++ b/flowming/src/tests/ArrayExpression.test.ts @@ -0,0 +1,130 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { Expression } from '../models/Expression'; +import { ExpressionElement, ExpressionElementType } from '../models/ExpressionElement'; +import { Variable } from '../models/Variable'; +import { ValuedVariable } from '../models/ValuedVariable'; + +// Helper utilities +let idCounter = 0; +const freshId = () => `elem-${idCounter++}`; +beforeEach(() => { idCounter = 0; }); + +const createElement = ( + type: ExpressionElementType, + value: string, + variableOrNested?: Variable | Expression +): ExpressionElement => new ExpressionElement(freshId(), type, value, variableOrNested); + +const createLiteral = (value: any) => createElement('literal', String(value)); +const createVariableEl = (variable: Variable) => createElement('variable', variable.name, variable); +const createOperator = (op: string) => createElement('operator', op); +const createFunctionEl = (fn: string, nested: Expression) => createElement('function', fn, nested); + +// Base array variable "arr" : integer[3] +const arrVar = new Variable('var-arr', 'array', 'arr', 'node-1', 'integer', 3); +// Index variable "i" : integer +const idxVar = new Variable('var-idx', 'integer', 'i', 'node-1'); + +// Valued variables providing runtime context +const valuedArr = new ValuedVariable('var-arr', 'array', 'arr', 'node-1', [0, 1, 2], 'integer', 3); +const valuedIdx = new ValuedVariable('var-idx', 'integer', 'i', 'node-1', 1); + +const defaultContext = [valuedArr.clone(), valuedIdx.clone()]; + +const makeArrayVariableWithIndex = (indexElements: ExpressionElement[]) => { + const v = arrVar.clone(); + v.indexExpression = indexElements; + return v; +}; + +describe('Array expressions – evaluation and assignment', () => { + + describe('Reading array elements', () => { + it('should evaluate arr[i] correctly using a variable index', () => { + const arrIdxVar = makeArrayVariableWithIndex([createVariableEl(idxVar)]); + const exprElements = [createVariableEl(arrIdxVar)]; + const expr = new Expression(undefined, exprElements); + expect(expr.calculateValue(exprElements, 'integer', defaultContext)).toBe(1); // arr[1] == 1 + }); + + it('should evaluate arr[2] correctly using a literal index', () => { + const arrIdxVar = makeArrayVariableWithIndex([createLiteral(2)]); + const exprElements = [createVariableEl(arrIdxVar)]; + const expr = new Expression(undefined, exprElements); + expect(expr.calculateValue(exprElements, 'integer', defaultContext)).toBe(2); + }); + + it('should throw on out-of-bounds access', () => { + const arrIdxVar = makeArrayVariableWithIndex([createLiteral(3)]); // size is 3 ⇒ max idx 2 + const expr = new Expression(undefined, [createVariableEl(arrIdxVar)]); + expect(() => expr.calculateValue(expr.rightSide, 'integer', defaultContext)) + .toThrow('out of bounds'); + }); + + it('should throw when index is not an integer', () => { + const arrIdxVar = makeArrayVariableWithIndex([createLiteral("'a'")]); + const expr = new Expression(undefined, [createVariableEl(arrIdxVar)]); + expect(() => expr.calculateValue(expr.rightSide, 'integer', defaultContext)) + .toThrow('must be an integer'); + }); + }); + + describe('Writing array elements', () => { + it('should assign to arr[0] = 10', () => { + const arrIdxVar = makeArrayVariableWithIndex([createLiteral(0)]); + const assignExpr = new Expression(arrIdxVar, [createLiteral(10)]); + const updated = assignExpr.assignValue([valuedArr.clone()]); + expect(updated.value).toEqual([10, 1, 2]); + }); + + it('should assign to arr[i] using variable index', () => { + const arrIdxVar = makeArrayVariableWithIndex([createVariableEl(idxVar)]); + const assignExpr = new Expression(arrIdxVar, [createLiteral(42)]); + const updated = assignExpr.assignValue(defaultContext); + const expected = [0, 42, 2]; // i == 1 + expect(updated.value).toEqual(expected); + }); + + it('should throw on type mismatch when assigning string into integer array', () => { + const arrIdxVar = makeArrayVariableWithIndex([createLiteral(1)]); + const assignExpr = new Expression(arrIdxVar, [createLiteral("'hello'")]); + expect(() => assignExpr.assignValue(defaultContext)) + .toThrow('Type mismatch'); + }); + + it('should assign using arithmetic index expression arr[i + 1] = 7', () => { + const complexIdx = [createVariableEl(idxVar), createOperator('+'), createLiteral(1)]; + const arrIdxVar = makeArrayVariableWithIndex(complexIdx); + const assignExpr = new Expression(arrIdxVar, [createLiteral(7)]); + const ctx = [ + new ValuedVariable('var-arr', 'array', 'arr', 'node-1', [0,1,2], 'integer', 3), + valuedIdx.clone(), + ]; + const updated = assignExpr.assignValue(ctx); + expect(updated.value).toEqual([0,1,7]); // i==1 so index 2 + }); + + it('should assign using function & arithmetic in index arr[int((i*2)-1)] = 9', () => { + const innerExpr = new Expression(undefined, [ + createVariableEl(idxVar), createOperator('*'), createLiteral(2), createOperator('-'), createLiteral(1) + ]); + const funcEl = createFunctionEl('integer', innerExpr); + const arrIdxVar = makeArrayVariableWithIndex([funcEl]); + const assignExpr = new Expression(arrIdxVar, [createLiteral(9)]); + + const ctx = [ + new ValuedVariable('var-arr', 'array', 'arr', 'node-1', [0,1,2], 'integer', 3), + valuedIdx.clone(), + ]; + const updated = assignExpr.assignValue(ctx); + // i=1 -> (1*2)-1 =1 => int(1)=1 + expect(updated.value).toEqual([0,9,2]); + }); + + it('should throw when assigning to whole array variable without index', () => { + const assignExpr = new Expression(arrVar.clone(), [createLiteral(5)]); + expect(() => assignExpr.assignValue(defaultContext)) + .toThrow('Cannot assign directly to array variable'); + }); + }); +}); \ No newline at end of file diff --git a/flowming/src/utils/codeGeneration.ts b/flowming/src/utils/codeGeneration.ts index a80f442..4e0a600 100644 --- a/flowming/src/utils/codeGeneration.ts +++ b/flowming/src/utils/codeGeneration.ts @@ -1,10 +1,10 @@ import { FlowNode } from '../components/Flow/FlowTypes'; import { Edge } from '@xyflow/react'; import { - Program, Statement, Expression as PyExpression, AssignmentStatement, IfStatement, WhileStatement, BlockStatement, Identifier, Literal, BinaryExpression, PrintStatement, UnsupportedNode, CallExpression, ASTNode, BreakStatement + Program, Statement, Expression as PyExpression, AssignmentStatement, IfStatement, WhileStatement, BlockStatement, Identifier, Literal, BinaryExpression, PrintStatement, UnsupportedNode, CallExpression, ASTNode, BreakStatement, SubscriptExpression } from '../models/pythonAST'; -import { Expression as DiagramExpression, ExpressionElement, Variable, IOperator, IExpression as DiagramIExpression } from '../models'; -import { buildAST, ExpressionASTNode as DiagramASTNode, UnaryOpNode, BinaryOpNode as DiagramBinaryOpNode, FunctionCallNode as DiagramFunctionCallNode, IdentifierNode as DiagramIdentifierNode, LiteralNode as DiagramLiteralNode } from '../models/ExpressionParser'; +import { Expression as DiagramExpression, ExpressionElement, Variable, IOperator, IExpression as DiagramIExpression, VariableType } from '../models'; +import { buildAST, ExpressionASTNode as DiagramASTNode, UnaryOpNode, BinaryOpNode as DiagramBinaryOpNode, FunctionCallNode as DiagramFunctionCallNode, IdentifierNode as DiagramIdentifierNode, LiteralNode as DiagramLiteralNode, MemberAccessNode } from '../models/ExpressionParser'; // TODO: check bugs images + extensive automated testing @@ -38,6 +38,11 @@ const convertDiagramASTToPythonAST = (diagramNode: DiagramASTNode, nodeId: strin case 'Identifier': { const identifier = diagramNode as DiagramIdentifierNode; + if (identifier.variable.type === 'array' && identifier.variable.indexExpression && identifier.variable.indexExpression.length > 0) { + const indexExpr = convertDiagramExpressionToAST(identifier.variable.indexExpression as ExpressionElement[], nodeId, cfg); + const objId: Identifier = { type: 'Identifier', name: identifier.name, diagramNodeId: nodeId } as Identifier; + return { type: 'SubscriptExpression', object: objId, index: indexExpr, diagramNodeId: nodeId } as any; + } return { type: 'Identifier', name: identifier.name, diagramNodeId: nodeId } as Identifier; } @@ -104,6 +109,14 @@ const convertDiagramASTToPythonAST = (diagramNode: DiagramASTNode, nodeId: strin } as CallExpression; } + case 'MemberAccess': { + const member = diagramNode as MemberAccessNode; + const obj = convertDiagramASTToPythonAST(member.object, nodeId, cfg); + const idx = convertDiagramASTToPythonAST(member.property, nodeId, cfg); + if (obj.type === 'UnsupportedNode' || idx.type === 'UnsupportedNode') return obj.type==='UnsupportedNode'?obj:idx; + return { type: 'SubscriptExpression', object: obj as Identifier, index: idx, diagramNodeId: nodeId } as any; + } + default: return unsupportedNode(`Unknown diagram AST node type: ${(diagramNode as any).type}`); } @@ -128,6 +141,16 @@ const convertDiagramExpressionToAST = (elements: ExpressionElement[], nodeId: st } }; +// Helper to convert Variable (left side) into Python target (Identifier or SubscriptExpression) +const variableToTarget = (v: Variable, nodeId: string, cfg: CFG): Identifier | SubscriptExpression => { + if (v.type === 'array' && v.indexExpression && v.indexExpression.length > 0) { + const idxAst = convertDiagramExpressionToAST(v.indexExpression as ExpressionElement[], nodeId, cfg); + const objId: Identifier = { type: 'Identifier', name: v.name, diagramNodeId: nodeId } as Identifier; + return { type: 'SubscriptExpression', object: objId, index: idxAst, diagramNodeId: nodeId } as any; + } + return { type: 'Identifier', name: v.name, diagramNodeId: nodeId } as Identifier; +}; + // Build a Control-Flow Graph (CFG) from nodes and edges interface CFG { nodesMap: Map; @@ -252,7 +275,7 @@ const generateNodeStatements = (node: FlowNode, nodeId: string, cfg: CFG): State if (node.data?.expression) { const diag = DiagramExpression.fromObject(node.data.expression as DiagramIExpression); if (diag.leftSide instanceof Variable) { - const target: Identifier = { type: 'Identifier', name: diag.leftSide.name, diagramNodeId: nodeId, visualId }; + const target = variableToTarget(diag.leftSide, nodeId, cfg); const val = convertDiagramExpressionToAST(diag.rightSide, nodeId, cfg); stmts.push( val.type !== 'UnsupportedNode' @@ -266,7 +289,7 @@ const generateNodeStatements = (node: FlowNode, nodeId: string, cfg: CFG): State case 'Input': { if (node.data?.variable) { const v = node.data.variable as Variable; - const target: Identifier = { type: 'Identifier', name: v.name, diagramNodeId: nodeId, visualId }; + const target = variableToTarget(v, nodeId, cfg); const promptText = `Enter the value of '${v.name}' by keyboard`; const promptLiteral: Literal = { type: 'Literal', value: promptText, raw: JSON.stringify(promptText), diagramNodeId: nodeId }; @@ -280,8 +303,13 @@ const generateNodeStatements = (node: FlowNode, nodeId: string, cfg: CFG): State } as CallExpression; let value: PyExpression = baseCall; - // TODO: new data types here - if (v.type === 'integer') { + // Handle type conversion. + let vType: VariableType = v.type; + if (v.type === 'array') { + vType = v.arraySubtype as VariableType; + } + + if (vType === 'integer') { // int(input()) value = { type: 'CallExpression', @@ -290,7 +318,7 @@ const generateNodeStatements = (node: FlowNode, nodeId: string, cfg: CFG): State diagramNodeId: nodeId, visualId } as CallExpression; - } else if (v.type === 'float') { + } else if (vType === 'float') { // float(input()) value = { type: 'CallExpression', @@ -299,7 +327,7 @@ const generateNodeStatements = (node: FlowNode, nodeId: string, cfg: CFG): State diagramNodeId: nodeId, visualId } as CallExpression; - } else if (v.type === 'boolean') { + } else if (vType === 'boolean') { // bool(input()) value = { type: 'CallExpression', @@ -801,6 +829,10 @@ const generateCodeFromASTNode = (astNode: ASTNode, indentLevel = 0): string => { const callArgs = callExpr.arguments.map(arg => generateCodeFromASTNode(arg)).join(', '); return `${generateCodeFromASTNode(callExpr.callee)}(${callArgs})`; + case 'SubscriptExpression': + const sub = astNode as SubscriptExpression; + return `${generateCodeFromASTNode(sub.object)}[${generateCodeFromASTNode(sub.index)}]`; + case 'UnsupportedNode': const unsupported = astNode as UnsupportedNode; const blockId = unsupported.visualId || unsupported.diagramNodeId || 'N/A'; diff --git a/flowming/src/utils/expressionParsing.ts b/flowming/src/utils/expressionParsing.ts deleted file mode 100644 index b60de93..0000000 --- a/flowming/src/utils/expressionParsing.ts +++ /dev/null @@ -1,103 +0,0 @@ -// Utility helpers for parsing expression-related strings -// Intended to be used by the panel editors to avoid code duplication - -import { Expression, ExpressionElement, Variable } from "@/models"; -import { operators } from "@/models/Expression"; - -// Parses array access literal strings (e.g., "arr[i]" or "arr[0:5]") and returns parts -export function parseArrayAccess(value: string): { - arrayName: string; - indexExpression?: string; - rangeStart?: string; - rangeEnd?: string; - isRange: boolean; -} | null { - const match = value.match(/^(.+?)\[(.+)\]$/); - if (!match) return null; - const arrayName = match[1]; - const indexPart = match[2]; - - if (indexPart.includes(":")) { - const [start, end] = indexPart.split(":"); - if (start !== undefined && end !== undefined) { - return { - arrayName, - rangeStart: start.trim(), - rangeEnd: end.trim(), - isRange: true, - }; - } - } - return { - arrayName, - indexExpression: indexPart.trim(), - isRange: false, - }; -} - -// Converts a raw string expression back into an Expression instance -// Requires the current list of variables to resolve identifiers -export function parseExpressionString(exprStr: string, allVariables: Variable[]): Expression { - const elements: ExpressionElement[] = []; - - const splitTokens = (s: string): string[] => { - const toks: string[] = []; - let current = ""; - let depth = 0; - for (let i = 0; i < s.length; i++) { - const ch = s[i]; - if (ch === " " && depth === 0) { - if (current !== "") { - toks.push(current); - current = ""; - } - continue; - } - if (ch === "(") depth++; - if (ch === ")") depth--; - current += ch; - } - if (current.trim() !== "") toks.push(current); - return toks; - }; - - const tokens = splitTokens(exprStr.trim()); - const functionRegex = /^(integer|string|float|boolean)\((.*)\)$/; - - tokens.forEach((tok) => { - // Function call - const funcMatch = tok.match(functionRegex); - if (funcMatch) { - const funcName = funcMatch[1]; - const inner = funcMatch[2]; - const nestedExpr = parseExpressionString(inner, allVariables); // recursion - elements.push( - new ExpressionElement(crypto.randomUUID(), "function", funcName, nestedExpr) - ); - return; - } - - // Operator token - if (operators.includes(tok as any)) { - elements.push(new ExpressionElement(crypto.randomUUID(), "operator", tok)); - return; - } - - // Variable or literal - const variable = allVariables.find((v) => v.name === tok); - if (variable) { - elements.push( - new ExpressionElement( - crypto.randomUUID(), - "variable", - variable.name, - variable - ) - ); - } else { - elements.push(new ExpressionElement(crypto.randomUUID(), "literal", tok)); - } - }); - - return new Expression(undefined, elements); -} \ No newline at end of file