diff --git a/.gitignore b/.gitignore index f2ca6377..93ac6f6c 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,4 @@ /src/NodeDev.ScriptRunner/obj /src/NodeDev.EndToEndTests/Features/*.feature.cs +/graphify-out diff --git a/src/NodeDev.Blazor/Components/GraphCanvas.razor b/src/NodeDev.Blazor/Components/GraphCanvas.razor index b5bae16f..2f524656 100644 --- a/src/NodeDev.Blazor/Components/GraphCanvas.razor +++ b/src/NodeDev.Blazor/Components/GraphCanvas.razor @@ -5,7 +5,7 @@ @if (IsShowingNodeSelection) {
- +
} @if (IsShowingGenericTypeSelection) @@ -39,4 +39,4 @@ - \ No newline at end of file + diff --git a/src/NodeDev.Blazor/Components/GraphCanvas.razor.cs b/src/NodeDev.Blazor/Components/GraphCanvas.razor.cs index 366de048..ea03b259 100644 --- a/src/NodeDev.Blazor/Components/GraphCanvas.razor.cs +++ b/src/NodeDev.Blazor/Components/GraphCanvas.razor.cs @@ -1,5 +1,6 @@ using Blazor.Diagrams; using Blazor.Diagrams.Core.Anchors; +using Blazor.Diagrams.Core.Behaviors; using Blazor.Diagrams.Core.Models; using Blazor.Diagrams.Core.Models.Base; using Blazor.Diagrams.Core.Routers; @@ -13,6 +14,7 @@ using NodeDev.Core.Connections; using NodeDev.Core.ManagerServices; using NodeDev.Core.Nodes; +using NodeDev.Core.Nodes.Delegates; using NodeDev.Core.Types; using System.Numerics; using System.Reactive.Linq; @@ -37,6 +39,7 @@ public partial class GraphCanvas : ComponentBase, IDisposable, IGraphCanvas private Vector2 PopupNodePosition; private Connection? PopupNodeConnection; private Node? PopupNode; + private string? PopupCallableScopeId; private BlazorDiagram Diagram { get; set; } = null!; @@ -45,6 +48,7 @@ public partial class GraphCanvas : ComponentBase, IDisposable, IGraphCanvas protected override void OnInitialized() { base.OnInitialized(); + _ = NodeProvider.WarmExtensionMethodCatalogAsync(); Graph.GraphCanvas = this; @@ -65,6 +69,10 @@ protected override void OnInitialized() }; Diagram = new BlazorDiagram(options); Diagram.RegisterComponent(); + Diagram.RegisterComponent(); + Diagram.Options.Constraints.ShouldDeleteNode = ShouldDeleteNode; + Diagram.Options.Constraints.ShouldDeleteGroup = ShouldDeleteGroup; + Diagram.GetBehavior()?.SetShortcut("Delete", false, false, false, DeleteSelection); Diagram.KeyDown += Diagram_KeyDown; Diagram.Nodes.Removed += OnNodeRemoved; @@ -143,13 +151,11 @@ private void OnGraphChangedFromCore((Graph, bool) _) public void UpdatePortColor(Connection connection) { - var node = Diagram.Nodes.OfType().FirstOrDefault(x => x.Node == connection.Parent); - if (node == null) + var port = FindPort(connection); + if (port == null) return; - var port = node.GetPort(connection); - - var color = GetTypeShapeColor(connection.Type, node.Node.TypeFactory); + var color = GetTypeShapeColor(connection.Type, connection.Parent.TypeFactory); foreach (var link in port.Links.Cast()) link.Color = color; @@ -168,6 +174,7 @@ private void UpdateNodes() DisableNodeRemovedUpdate = true; Diagram.Links.Clear(); + Diagram.Groups.Clear(); Diagram.Nodes.Clear(); InitializeCanvasWithGraphNodes(); @@ -177,10 +184,74 @@ private void UpdateNodes() }); } + private NodeModel? FindNodeModel(Node node) + { + return (NodeModel?)Diagram.Nodes.OfType().FirstOrDefault(x => x.Node == node) + ?? Diagram.Groups.OfType().FirstOrDefault(x => x.DelegateNode == node); + } + + private GraphPortModel? FindPort(Connection connection) + { + var nodePort = FindNodeModel(connection.Parent)?.Ports + .OfType() + .FirstOrDefault(x => x.Connection == connection); + if (nodePort != null) + return nodePort; + + return Diagram.Groups + .SelectMany(x => x.Ports) + .OfType() + .FirstOrDefault(x => x.Connection == connection); + } + + private LambdaGroupModel? FindLambdaGroup(string? bodyScopeId) => Diagram.Groups + .OfType() + .FirstOrDefault(x => x.DelegateNode.BodyScopeId == bodyScopeId); + #endregion #region Events from client + private static ValueTask ShouldDeleteNode(NodeModel node) + { + return ValueTask.FromResult(node is not GraphNodeModel { Node: LambdaEntryNode }); + } + + private ValueTask ShouldDeleteGroup(GroupModel group) + { + if (group is not LambdaGroupModel lambdaGroup) + return ValueTask.FromResult(true); + + GraphManagerService.RemoveNode(lambdaGroup.DelegateNode); + return ValueTask.FromResult(false); + } + + private async ValueTask DeleteSelection(global::Blazor.Diagrams.Core.Diagram diagram) + { + var selectedGroups = Diagram.Groups + .OfType() + .Where(x => x.Selected) + .ToHashSet(); + + foreach (var group in selectedGroups.Where(x => !HasSelectedLambdaAncestor(x, selectedGroups)).ToArray()) + GraphManagerService.RemoveNode(group.DelegateNode); + + await KeyboardShortcutsDefaults.DeleteSelection(diagram); + } + + private static bool HasSelectedLambdaAncestor(LambdaGroupModel group, HashSet selectedGroups) + { + var parent = group.Group; + while (parent != null) + { + if (parent is LambdaGroupModel lambdaParent && selectedGroups.Contains(lambdaParent)) + return true; + parent = parent.Group; + } + + return false; + } + #region Node Removed bool DisableNodeRemovedUpdate = false; @@ -190,17 +261,20 @@ public void OnNodeRemoved(NodeModel nodeModel) if (DisableNodeRemovedUpdate) return; - var node = ((GraphNodeModel)nodeModel).Node; + if (nodeModel is not GraphNodeModel graphNodeModel) + return; + + var node = graphNodeModel.Node; foreach (var input in node.Inputs) { - foreach (var connection in input.Connections) + foreach (var connection in input.Connections.ToList()) GraphManagerService.DisconnectConnectionBetween(input, connection); } foreach (var output in node.Outputs) { - foreach (var connection in output.Connections) + foreach (var connection in output.Connections.ToList()) GraphManagerService.DisconnectConnectionBetween(output, connection); } @@ -234,7 +308,7 @@ private void OnConnectionUpdated(BaseLinkModel baseLinkModel, Anchor old, Anchor (destination, source) = (source, destination); } - GraphManagerService.AddNewConnectionBetween(source.Connection, destination.Connection); + GraphManagerService.AddNewConnectionBetweenOrCapture(source.Connection, destination.Connection); } /// @@ -350,6 +424,21 @@ public static void OnNodeMoved(MovableModel movableModel) decoration.Position = new((float)movableModel.Position.X, (float)movableModel.Position.Y); } + private static void OnLambdaGroupMoved(MovableModel movableModel) + { + if (movableModel is not LambdaGroupModel group) + return; + + var groupDecoration = group.DelegateNode.GetOrAddDecoration(() => new(Vector2.Zero)); + groupDecoration.Position = new((float)group.Position.X, (float)group.Position.Y); + + foreach (var nodeModel in group.GetDescendantNodeModels()) + { + var decoration = nodeModel.Node.GetOrAddDecoration(() => new(Vector2.Zero)); + decoration.Position = new((float)nodeModel.Position.X, (float)nodeModel.Position.Y); + } + } + #endregion #region OnPortDroppedOnCanvas @@ -360,6 +449,7 @@ public void OnPortDroppedOnCanvas(Connection connection, global::Blazor.Diagrams { PopupNode = connection.Parent; PopupNodeConnection = connection; + PopupCallableScopeId = connection.Parent.CallableScopeId; var screenPosition = Diagram.GetScreenPoint(point.X, point.Y) - Diagram.Container!.NorthWest; PopupX = (int)screenPosition.X; PopupY = (int)screenPosition.Y; @@ -374,7 +464,7 @@ private void OnNewNodeTypeSelected(NodeProvider.NodeSearchResult searchResult) var node = GraphManagerService.AddNode(searchResult, node => { node.AddDecoration(new NodeDecorationPosition(new(PopupNodePosition.X, PopupNodePosition.Y))); - }); + }, PopupCallableScopeId); Diagram.Batch(() => { @@ -449,9 +539,11 @@ private void OnNewOverloadSelected(Node.AlternateOverload overload) private bool IsShowingGenericTypeSelection = false; private string? GenericTypeSelectionMenuGeneric; + private Action? PopupTypeSelectedAction; public void OnGenericTypeSelectionMenuAsked(GraphNodeModel nodeModel, string undefinedGenericType) { + PopupTypeSelectedAction = null; PopupNode = nodeModel.Node; var p = Diagram.GetScreenPoint(nodeModel.Position.X, nodeModel.Position.Y) - Diagram.Container!.NorthWest; PopupX = (int)p.X; @@ -464,6 +556,13 @@ public void OnGenericTypeSelectionMenuAsked(GraphNodeModel nodeModel, string und private void OnGenericTypeSelected(TypeBase type) { + if (PopupTypeSelectedAction != null) + { + PopupTypeSelectedAction(type); + CancelPopup(); + return; + } + if (PopupNode == null || GenericTypeSelectionMenuGeneric == null) return; @@ -475,6 +574,20 @@ private void OnGenericTypeSelected(TypeBase type) CancelPopup(); } + public void ShowLambdaTypeSelector(LambdaGroupModel group, Action onTypeSelected) + { + PopupNode = group.DelegateNode; + GenericTypeSelectionMenuGeneric = null; + PopupTypeSelectedAction = onTypeSelected; + var point = Diagram.GetScreenPoint(group.Position.X + group.Padding, group.Position.Y + 30); + if (Diagram.Container != null) + point -= Diagram.Container.NorthWest; + PopupX = (int)point.X; + PopupY = (int)point.Y; + IsShowingGenericTypeSelection = true; + StateHasChanged(); + } + #endregion #region OnTextboxValueChanged @@ -569,9 +682,38 @@ private void Diagram_KeyDown(global::Blazor.Diagrams.Core.Events.KeyboardEventAr public void ShowAddNode() { + ShowAddNodeAtScope(null, new Vector2(300, 300)); + } + + public void ShowAddNodeForScope(LambdaGroupModel group) + { + var bodyPosition = new Vector2( + (float)(group.Position.X + group.Padding), + (float)(group.Position.Y + group.Padding + 20)); + ShowAddNodeAtScope(group.DelegateNode.BodyScopeId, bodyPosition); + } + + private void ShowAddNodeAtScope(string? callableScopeId, Vector2 position) + { + PopupNode = null; + PopupNodeConnection = null; + PopupCallableScopeId = callableScopeId; + PopupNodePosition = position; + + if (Diagram.Container != null) + { + var screenPosition = Diagram.GetScreenPoint(position.X, position.Y) - Diagram.Container.NorthWest; + PopupX = (int)screenPosition.X; + PopupY = (int)screenPosition.Y; + } + else + { + PopupX = (int)position.X; + PopupY = (int)position.Y; + } + IsShowingNodeSelection = true; - PopupX = 300; - PopupY = 300; + StateHasChanged(); } public void ShowAddNodeDialog() @@ -589,6 +731,9 @@ private void CancelPopup() IsShowingGenericTypeSelection = IsShowingNodeSelection = IsShowingOverloadSelection = false; PopupNode = null; PopupNodeConnection = null; + PopupCallableScopeId = null; + PopupTypeSelectedAction = null; + GenericTypeSelectionMenuGeneric = null; } #endregion @@ -623,10 +768,43 @@ public void ToggleBreakpointOnSelectedNode() public void RemoveNode(Node node) { - var nodeModel = Diagram.Nodes.OfType().FirstOrDefault(x => x.Node == node); + if (node is LambdaReturnNode { IsImplicit: true } boundaryReturn) + { + var group = FindLambdaGroup(boundaryReturn.CallableScopeId); + if (group != null) + { + foreach (var port in group.Ports + .OfType() + .Where(x => x.Connection.Parent == boundaryReturn) + .ToList()) + { + group.RemovePort(port); + } + group.Refresh(); + } + return; + } - if (nodeModel != null) - Diagram.Nodes.Remove(nodeModel); + var nodeModel = FindNodeModel(node); + if (nodeModel == null) + return; + + var previousNodeSuppression = DisableNodeRemovedUpdate; + var previousConnectionSuppression = DisableConnectionUpdate; + DisableNodeRemovedUpdate = true; + DisableConnectionUpdate = true; + try + { + if (nodeModel is LambdaGroupModel group) + Diagram.Groups.Remove(group); + else + Diagram.Nodes.Remove(nodeModel); + } + finally + { + DisableNodeRemovedUpdate = previousNodeSuppression; + DisableConnectionUpdate = previousConnectionSuppression; + } } #endregion @@ -635,6 +813,7 @@ public void RemoveNode(Node node) public void RemoveLinkFromGraphCanvas(Connection source, Connection destination) { + var previousConnectionSuppression = DisableConnectionUpdate; DisableConnectionUpdate = true; try { @@ -646,22 +825,21 @@ public void RemoveLinkFromGraphCanvas(Connection source, Connection destination) } finally { - DisableConnectionUpdate = false; + DisableConnectionUpdate = previousConnectionSuppression; } } public void AddLinkToGraphCanvas(Connection source, Connection destination) { + var previousConnectionSuppression = DisableConnectionUpdate; DisableConnectionUpdate = true; try { if (source.IsInput) (destination, source) = (source, destination); - var sourceNode = Diagram.Nodes.OfType().First(x => x.Node == source.Parent); - var destinationNode = Diagram.Nodes.OfType().First(x => x.Node == destination.Parent); - var sourcePort = sourceNode.GetPort(source); - var destinationPort = destinationNode.GetPort(destination); + var sourcePort = FindPort(source) ?? throw new InvalidOperationException($"No canvas port exists for {source.Parent.Name}.{source.Name}."); + var destinationPort = FindPort(destination) ?? throw new InvalidOperationException($"No canvas port exists for {destination.Parent.Name}.{destination.Name}."); // Make sure there isn't already an existing identical link if (Diagram.Links.OfType().Any(x => (x.Source as SinglePortAnchor)?.Port == sourcePort && (x.Target as SinglePortAnchor)?.Port == destinationPort)) @@ -673,7 +851,7 @@ public void AddLinkToGraphCanvas(Connection source, Connection destination) } finally { - DisableConnectionUpdate = false; + DisableConnectionUpdate = previousConnectionSuppression; } } @@ -683,11 +861,101 @@ public void AddLinkToGraphCanvas(Connection source, Connection destination) public void AddNode(Node node) { + if (node is CreateDelegateNode delegateNode) + AddLambdaGroupModel(delegateNode); + else if (node is LambdaReturnNode { IsImplicit: true } boundaryReturn) + AddBoundaryReturnToGroup(boundaryReturn); + else + AddGraphNodeModel(node); + + ReparentScopedModels(); + } + + private GraphNodeModel AddGraphNodeModel(Node node) + { + EnsureInitialScopedPosition(node); var nodeModel = Diagram.Nodes.Add(new GraphNodeModel(node)); foreach (var connection in node.InputsAndOutputs) nodeModel.AddPort(new GraphPortModel(nodeModel, connection, node.Inputs.Contains(connection))); nodeModel.Moved += OnNodeMoved; + return nodeModel; + } + + private void EnsureInitialScopedPosition(Node node) + { + if (node.CallableScopeId == null || node.HasDecoration()) + return; + + var owner = Graph.GetOwningLambda(node.CallableScopeId); + if (owner == null) + return; + + var ownerPosition = owner.GetOrAddDecoration(() => new(Vector2.Zero)).Position; + var groupPadding = FindLambdaGroup(owner.BodyScopeId)?.Padding ?? LambdaGroupModel.MinimumPadding; + var existingNodesInScope = Diagram.Nodes + .OfType() + .Count(x => x.Node.CallableScopeId == node.CallableScopeId); + var offset = new Vector2(groupPadding + existingNodesInScope * 220, groupPadding); + node.AddDecoration(new NodeDecorationPosition(ownerPosition + offset)); + } + + private LambdaGroupModel AddLambdaGroupModel(CreateDelegateNode node) + { + var group = Diagram.Groups.Add(new LambdaGroupModel(node)); + foreach (var capture in node.CaptureInputs) + group.AddPort(new GraphPortModel(group, capture, true)); + group.AddPort(new GraphPortModel(group, node.DelegateOutput, false)); + if (group.BoundaryReturn is { } boundaryReturn) + AddBoundaryReturnPorts(group, boundaryReturn); + + group.Moved += OnLambdaGroupMoved; + return group; + } + + private void AddBoundaryReturnToGroup(LambdaReturnNode boundaryReturn) + { + var group = FindLambdaGroup(boundaryReturn.CallableScopeId); + if (group == null) + return; + + AddBoundaryReturnPorts(group, boundaryReturn); + group.Refresh(); + } + + private static void AddBoundaryReturnPorts(LambdaGroupModel group, LambdaReturnNode boundaryReturn) + { + foreach (var connection in boundaryReturn.Inputs) + { + if (group.Ports.OfType().All(x => x.Connection != connection)) + group.AddPort(new GraphPortModel(group, connection, true)); + } + } + + private void ReparentScopedModels() + { + var groupsByScope = Diagram.Groups + .OfType() + .ToDictionary(x => x.DelegateNode.BodyScopeId); + + foreach (var nodeModel in Diagram.Nodes.OfType()) + AttachToScope(nodeModel, nodeModel.Node.CallableScopeId, groupsByScope); + + foreach (var group in Diagram.Groups.OfType()) + AttachToScope(group, group.DelegateNode.CallableScopeId, groupsByScope); + + foreach (var rootGroup in Diagram.Groups.OfType().Where(x => x.Group == null)) + Diagram.SendToBack(rootGroup); + } + + private static void AttachToScope(NodeModel model, string? scopeId, IReadOnlyDictionary groupsByScope) + { + groupsByScope.TryGetValue(scopeId ?? string.Empty, out var desiredGroup); + if (model.Group == desiredGroup) + return; + + model.Group?.RemoveChild(model); + desiredGroup?.AddChild(model); } #endregion @@ -696,14 +964,19 @@ public void AddNode(Node node) private void AddNodeLinks(Node node, bool onlyOutputs) { - var nodeModel = Diagram.Nodes.OfType().First(x => x.Node == node); + var addedConnections = new HashSet<(string Source, string Target)>(); foreach (var connection in onlyOutputs ? node.Outputs : node.InputsAndOutputs) // just process the outputs so we don't connect "input to output" and "output to input" on the same connections { - var portModel = nodeModel.GetPort(connection); + var portModel = FindPort(connection) ?? throw new InvalidOperationException($"No canvas port exists for {node.Name}.{connection.Name}."); foreach (var other in connection.Connections) { - var otherNodeModel = Diagram.Nodes.OfType().First(x => x.Node == other.Parent); - var otherPortModel = otherNodeModel.GetPort(other); + var connectionKey = connection.IsOutput + ? (connection.Id, other.Id) + : (other.Id, connection.Id); + if (!addedConnections.Add(connectionKey)) + continue; + + var otherPortModel = FindPort(other) ?? throw new InvalidOperationException($"No canvas port exists for {other.Parent.Name}.{other.Name}."); var source = portModel; var target = otherPortModel; @@ -718,10 +991,17 @@ private void AddNodeLinks(Node node, bool onlyOutputs) // disable the connection update while adding the link so we can call it ourself and 'force' it to be sure it actually runs // if we don't do that, we'll have to call it again after adding the link and put the 'force' parameter to true, but then // it might be run twice, resulting in all callbacks being called twice! + var previousConnectionSuppression = DisableConnectionUpdate; DisableConnectionUpdate = true; - var link = Diagram.Links.Add(new LinkModel(source, target)); - - DisableConnectionUpdate = false; + LinkModel link; + try + { + link = Diagram.Links.Add(new LinkModel(source, target)); + } + finally + { + DisableConnectionUpdate = previousConnectionSuppression; + } OnConnectionAdded(link, true); var connectionWithVertices = GetConnectionContainingVertices(source.Connection, target.Connection); @@ -752,24 +1032,64 @@ private void AddNodeLinks(Node node, bool onlyOutputs) public void Refresh(Node node) { - var nodeModel = Diagram.Nodes.OfType().FirstOrDefault(x => x.Node == node); + if (node is CreateDelegateNode) + { + UpdateNodes(); + return; + } + if (node is LambdaReturnNode { IsImplicit: true } boundaryReturn) + { + var group = FindLambdaGroup(boundaryReturn.CallableScopeId); + group?.Refresh(); + return; + } + + var nodeModel = FindNodeModel(node) as GraphNodeModel; if (nodeModel == null) return; - // When overload is selected, the node's connections change - // We need to rebuild the ports to reflect the new connections - - // Remove old ports var oldPorts = nodeModel.Ports.ToList(); - foreach (var port in oldPorts) - nodeModel.RemovePort(port); + var expectedPorts = node.InputsAndOutputs + .Select(connection => (Connection: connection, IsInput: node.Inputs.Contains(connection))) + .ToList(); + var portsAreUnchanged = oldPorts.Count == expectedPorts.Count && expectedPorts.All(expected => + oldPorts.OfType().Any(port => + port.Connection == expected.Connection && + (port.Alignment == PortAlignment.Left) == expected.IsInput)); + + // Type-only updates keep the same Connection instances. Reusing their ports is + // important because existing diagram links are anchored to those port objects. + if (portsAreUnchanged) + { + nodeModel.Refresh(); + return; + } - // Add new ports based on updated node connections - foreach (var connection in node.InputsAndOutputs) - nodeModel.AddPort(new GraphPortModel(nodeModel, connection, node.Inputs.Contains(connection))); + Diagram.Batch(() => + { + var previousConnectionSuppression = DisableConnectionUpdate; + DisableConnectionUpdate = true; + try + { + // Links must be removed before their old ports. The core connections remain + // intact and are re-rendered against the replacement ports below. + foreach (var link in oldPorts.SelectMany(port => port.Links).Distinct().ToList()) + Diagram.Links.Remove(link); - // Refresh the node model to trigger UI update - nodeModel?.Refresh(); + foreach (var port in oldPorts) + nodeModel.RemovePort(port); + + foreach (var expectedPort in expectedPorts) + nodeModel.AddPort(new GraphPortModel(nodeModel, expectedPort.Connection, expectedPort.IsInput)); + } + finally + { + DisableConnectionUpdate = previousConnectionSuppression; + } + + AddNodeLinks(node, onlyOutputs: false); + nodeModel.Refresh(); + }); } #endregion @@ -778,9 +1098,13 @@ public void Refresh(Node node) private void InitializeCanvasWithGraphNodes() { - // add the nodes themselves - foreach (var node in Graph.Nodes.Values) - AddNode(node); + foreach (var delegateNode in Graph.Nodes.Values.OfType()) + AddLambdaGroupModel(delegateNode); + + foreach (var node in Graph.Nodes.Values.Where(x => x is not CreateDelegateNode and not LambdaReturnNode { IsImplicit: true })) + AddGraphNodeModel(node); + + ReparentScopedModels(); // add links foreach (var node in Graph.Nodes.Values) diff --git a/src/NodeDev.Blazor/Components/NodeSelection.razor b/src/NodeDev.Blazor/Components/NodeSelection.razor index 152fcb5f..0d0d3241 100644 --- a/src/NodeDev.Blazor/Components/NodeSelection.razor +++ b/src/NodeDev.Blazor/Components/NodeSelection.razor @@ -6,10 +6,10 @@ - + - @foreach (var type in NodeDev.Core.NodeProvider.Search(GraphCanvas.Graph.SelfClass.Project, SearchText, Connection).Take(1000)) + @foreach (var type in SearchResults) { @if (type is NodeDev.Core.NodeProvider.MethodCallNode methodCallNode) { @@ -47,21 +47,47 @@ [Parameter] public NodeDev.Core.Connections.Connection? Connection { get; set; } = null!; + [Parameter] + public string? CallableScopeId { get; set; } + [Parameter] public GraphCanvas GraphCanvas { get; set; } = null!; [Parameter] public EventCallback OnNodeTypeSelected { get; set; } - private TypeFactory TypeFactory => GraphCanvas.Graph.SelfClass.Project.TypeFactory; - + private const int MaxSearchResults = 100; private string SearchText { get; set; } = ""; + private IReadOnlyList SearchResults { get; set; } = []; + private NodeDev.Core.Graph? LastGraph; + private NodeDev.Core.Connections.Connection? LastConnection; + private string? LastCallableScopeId; + + protected override void OnParametersSet() + { + var graph = GraphCanvas.Graph; + if (graph == LastGraph && Connection == LastConnection && CallableScopeId == LastCallableScopeId) + return; + + LastGraph = graph; + LastConnection = Connection; + LastCallableScopeId = CallableScopeId; + UpdateSearchResults(); + } + + private void UpdateSearchResults() + { + SearchResults = NodeDev.Core.NodeProvider + .Search(GraphCanvas.Graph, SearchText, Connection, CallableScopeId) + .Take(MaxSearchResults) + .ToList(); + } private async Task OnKeyDown(KeyboardEventArgs args) { if (args.Key == "Enter") { - var first = NodeDev.Core.NodeProvider.Search(GraphCanvas.Graph.SelfClass.Project, SearchText, Connection).FirstOrDefault(); + var first = SearchResults.FirstOrDefault(); if (first != null) await OnNodeTypeSelected.InvokeAsync(first); } diff --git a/src/NodeDev.Blazor/DiagramsModels/GraphPortModel.cs b/src/NodeDev.Blazor/DiagramsModels/GraphPortModel.cs index a1f2d45a..236e2cab 100644 --- a/src/NodeDev.Blazor/DiagramsModels/GraphPortModel.cs +++ b/src/NodeDev.Blazor/DiagramsModels/GraphPortModel.cs @@ -13,7 +13,7 @@ public class GraphPortModel : PortModel internal string PortColor => GraphCanvas.GetTypeShapeColor(Connection.Type, Connection.Parent.TypeFactory); - public GraphPortModel(GraphNodeModel parent, Connection connection, bool isInput) : base(parent, isInput ? PortAlignment.Left : PortAlignment.Right) + public GraphPortModel(NodeModel parent, Connection connection, bool isInput) : base(parent, isInput ? PortAlignment.Left : PortAlignment.Right) { Connection = connection; } @@ -29,6 +29,16 @@ public override bool CanAttachTo(ILinkable other) if (Alignment == otherPort.Alignment) // can't plug input to input or output to output return false; - return Connection.Type.IsAssignableTo(otherPort.Connection.Type, out _, out _); + var source = Connection.IsOutput ? Connection : otherPort.Connection; + var destination = Connection.IsInput ? Connection : otherPort.Connection; + if (source.Parent.CallableScopeId != destination.Parent.CallableScopeId) + { + if (source.Type.IsExec || destination.Type.IsExec) + return false; + if (!source.Parent.Graph.IsScopeAncestorOf(source.Parent.CallableScopeId, destination.Parent.CallableScopeId)) + return false; + } + + return source.Type.IsAssignableTo(destination.Type, out _, out _); } } diff --git a/src/NodeDev.Blazor/DiagramsModels/LambdaGroupModel.cs b/src/NodeDev.Blazor/DiagramsModels/LambdaGroupModel.cs new file mode 100644 index 00000000..35e3565d --- /dev/null +++ b/src/NodeDev.Blazor/DiagramsModels/LambdaGroupModel.cs @@ -0,0 +1,105 @@ +using Blazor.Diagrams.Core.Geometry; +using Blazor.Diagrams.Core.Models; +using NodeDev.Blazor.NodeAttributes; +using NodeDev.Core.Connections; +using NodeDev.Core.Nodes; +using NodeDev.Core.Nodes.Delegates; + +namespace NodeDev.Blazor.DiagramsModels; + +/// +/// UI projection of a delegate-creation node and its callable body. Scope membership +/// in the core graph remains the source of truth; Children is only the live canvas view. +/// +public sealed class LambdaGroupModel : GroupModel +{ + public const byte MinimumPadding = 60; + public const byte FuncPadding = MinimumPadding; + public const double MinimumWidth = 600; + public const double MinimumHeight = 420; + + public LambdaGroupModel(CreateDelegateNode delegateNode, IEnumerable? children = null) + : base(CreateChildren(delegateNode, children), CalculatePadding(delegateNode)) + { + DelegateNode = delegateNode; + } + + public CreateDelegateNode DelegateNode { get; } + public LambdaReturnNode? BoundaryReturn => DelegateNode.Graph + .GetNodesInScope(DelegateNode.BodyScopeId) + .OfType() + .SingleOrDefault(x => x.IsImplicit); + + public GraphPortModel GetPort(Connection connection) => + Ports.OfType().First(x => x.Connection == connection); + + public IEnumerable GetDescendantNodeModels() + { + foreach (var child in Children) + { + if (child is GraphNodeModel node) + { + yield return node; + } + else if (child is LambdaGroupModel group) + { + foreach (var descendant in group.GetDescendantNodeModels()) + yield return descendant; + } + } + } + + private static byte CalculatePadding(CreateDelegateNode node) + { + var minimum = node.Kind == DelegateKind.Func ? FuncPadding : MinimumPadding; + return (byte)Math.Min(byte.MaxValue, minimum + node.CaptureInputs.Count * 15); + } + + private static Point GetInitialPosition(CreateDelegateNode node, byte padding) + { + var childPositions = node.Graph.GetNodesInScope(node.BodyScopeId) + .Where(x => x is not LambdaReturnNode { IsImplicit: true }) + .Where(x => x.Decorations.TryGetValue(typeof(NodeDecorationPosition), out _)) + .Select(x => (NodeDecorationPosition)x.Decorations[typeof(NodeDecorationPosition)]) + .ToList(); + if (childPositions.Count != 0) + { + return new Point( + childPositions.Min(x => x.X) - padding, + childPositions.Min(x => x.Y) - padding); + } + + var position = node.GetOrAddDecoration(() => new(System.Numerics.Vector2.Zero)); + return new Point(position.X, position.Y); + } + + private static IEnumerable CreateChildren(CreateDelegateNode node, IEnumerable? children) + { + var padding = CalculatePadding(node); + var position = GetInitialPosition(node, padding); + var layoutSize = new Size( + Math.Max(1, MinimumWidth - padding * 2), + Math.Max(1, MinimumHeight - padding * 2)); + yield return new LambdaLayoutModel( + new Point(position.X + padding, position.Y + padding), + layoutSize); + + foreach (var child in children ?? []) + yield return child; + } + + /// + /// An invisible child that gives the auto-sized group a stable minimum workspace. + /// Real children can still grow the group when they move outside these bounds. + /// + private sealed class LambdaLayoutModel : NodeModel + { + public LambdaLayoutModel(Point position, Size size) : base(position) + { + Size = size; + ControlledSize = true; + Locked = true; + Visible = false; + } + } +} diff --git a/src/NodeDev.Blazor/DiagramsModels/LambdaGroupWidget.razor b/src/NodeDev.Blazor/DiagramsModels/LambdaGroupWidget.razor new file mode 100644 index 00000000..7ea47d95 --- /dev/null +++ b/src/NodeDev.Blazor/DiagramsModels/LambdaGroupWidget.razor @@ -0,0 +1,115 @@ +@using global::Blazor.Diagrams.Core.Models +@using NodeDev.Core.Nodes.Delegates + +
+
+
+ λ @Group.DelegateNode.SignatureDisplayName + +
+
+ @if (Group.DelegateNode is CreateFuncNode) + { + + } + @foreach (var parameter in Group.DelegateNode.Parameters) + { +
+ + +
+ } + @foreach (var capture in Group.DelegateNode.Captures) + { +
+ + +
+ } + +
+
+ + @for (var i = 0; i < Group.DelegateNode.CaptureInputs.Count; i++) + { + var capture = Group.DelegateNode.CaptureInputs[i]; + var port = Group.GetPort(capture); + var top = 86 + i * 30; +
+ + @capture.Name +
+ } + + @{ + var delegatePort = Group.GetPort(Group.DelegateNode.DelegateOutput); + } +
+ @Group.DelegateNode.DelegateOutput.Name + +
+ + @if (Group.BoundaryReturn is { } boundaryReturn) + { + var execPort = Group.GetPort(boundaryReturn.ExecInput); + var resultPort = Group.GetPort(boundaryReturn.ResultInput); +
+ Return +
+ + @boundaryReturn.ExecInput.Name +
+
+ + @boundaryReturn.ResultInput.Name + @if (boundaryReturn.ResultInput.Type.AllowTextboxEdit && resultPort.Links.Count == 0) + { + + } +
+
+ } + + +
+ +@code { + [Parameter, EditorRequired] + public LambdaGroupModel Group { get; set; } = null!; + + [CascadingParameter] + public GraphCanvas GraphCanvas { get; set; } = null!; + + private void AddNode() => GraphCanvas.ShowAddNodeForScope(Group); + + private void AddParameter() => Group.DelegateNode.AddParameter(); + private void RemoveParameter(string id) => Group.DelegateNode.RemoveParameter(id); + private void RemoveCapture(string id) => Group.DelegateNode.RemoveCapture(id); + + private void SelectResultType() => + GraphCanvas.ShowLambdaTypeSelector(Group, Group.DelegateNode.SetResultType); + + private void SelectParameterType(LambdaParameterDefinition parameter) => + GraphCanvas.ShowLambdaTypeSelector(Group, type => Group.DelegateNode.UpdateParameter(parameter.Id, parameter.Name, type)); + + private void SelectCaptureType(LambdaCaptureDefinition capture) => + GraphCanvas.ShowLambdaTypeSelector(Group, type => Group.DelegateNode.UpdateCapture(capture.Id, capture.Name, type)); +} diff --git a/src/NodeDev.Blazor/wwwroot/styles.css b/src/NodeDev.Blazor/wwwroot/styles.css index 056c6ff4..d97c415e 100644 --- a/src/NodeDev.Blazor/wwwroot/styles.css +++ b/src/NodeDev.Blazor/wwwroot/styles.css @@ -181,4 +181,212 @@ g.diagram-link path:not(.selection-helper) { .debugged-path:hover { cursor: pointer; -} \ No newline at end of file +} + +.diagram-group:has(> .lambda-region) { + outline: 2px solid #26878a; + border-radius: 12px; + background: rgba(64, 186, 189, 0.10); +} + +.diagram-group.selected:has(> .lambda-region) { + outline-color: #111827; + box-shadow: 0 0 0 2px rgba(64, 186, 189, 0.35); +} + +.lambda-region { + position: absolute; + inset: 0; + border-radius: 12px; + pointer-events: none; +} + +.lambda-region-header { + position: absolute; + top: 0; + left: 0; + right: 0; + min-height: 42px; + padding: 6px 8px 6px 14px; + border-bottom: 1px solid rgba(38, 135, 138, 0.55); + border-radius: 12px 12px 0 0; + background: rgba(64, 186, 189, 0.20); + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + pointer-events: all; +} + +.lambda-region-title-row, +.lambda-signature-editor { + display: flex; + align-items: center; + gap: 0.3rem; +} + +.lambda-region-title-row { + justify-content: space-between; + flex: 1 1 auto; + min-width: 0; +} + +.lambda-signature-editor { + flex: 0 1 auto; + flex-wrap: wrap; + justify-content: flex-end; + font-size: 0.7rem; +} + +.lambda-signature-chip { + display: inline-flex; + align-items: center; + border: 1px solid rgba(79, 70, 229, 0.35); + border-radius: 999px; + background: rgba(255, 255, 255, 0.8); + overflow: hidden; +} + +.lambda-signature-chip button, +.lambda-result-chip, +.lambda-signature-add { + border: 0; + background: transparent; + color: inherit; + cursor: pointer; + padding: 0.12rem 0.35rem; +} + +.lambda-signature-chip button:hover, +.lambda-signature-add:hover { + background: rgba(79, 70, 229, 0.12); +} + +.lambda-capture-chip { + border-color: rgba(5, 150, 105, 0.45); +} + +.lambda-result-chip, +.lambda-signature-add { + border: 1px dashed rgba(79, 70, 229, 0.55); + border-radius: 999px; +} + +.lambda-result-chip { + max-width: 180px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.lambda-chip-remove { + font-weight: 700; + padding-left: 0.2rem !important; +} + +.lambda-region-signature { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: #164e50; + font-weight: 700; +} + +.lambda-region-add { + flex: none; +} + +.lambda-boundary-row { + position: absolute; + min-height: 24px; + display: flex; + align-items: center; + gap: 7px; + padding: 2px 8px; + border-radius: 6px; + background: rgba(255, 255, 255, 0.88); + color: #164e50; + font-size: 0.78rem; + pointer-events: all; +} + +.lambda-capture-row { + left: 0; + transform: translateX(-1px); +} + +.lambda-delegate-row { + right: 0; + top: 50%; + transform: translate(1px, -50%); +} + +.lambda-return-boundary { + position: absolute; + right: -1px; + bottom: 18px; + width: 156px; + box-sizing: border-box; + overflow: visible; + padding: 0 10px 7px 14px; + border: 1px solid rgba(185, 28, 28, 0.45); + border-right: 0; + border-radius: 8px 0 0 8px; + background: rgba(254, 242, 242, 0.94); + color: #7f1d1d; + font-size: 0.75rem; + pointer-events: all; +} + +.lambda-return-title { + display: block; + margin: -1px -10px 5px -14px; + padding: 4px 10px 4px 14px; + border-radius: 8px 0 0 0; + background: #dc2626; + color: white; + font-weight: 700; +} + +.lambda-return-port { + position: relative; + min-height: 24px; + display: flex; + align-items: center; + gap: 7px; +} + +.lambda-return-port .diagram-port { + width: 18px; + height: 18px; + flex: none; + border: 1px solid #d4d4d4; + border-radius: 9999px; + cursor: pointer; + position: relative; + margin: 0; + pointer-events: all; +} + +.lambda-return-editor { + width: 72px; + min-width: 0; + padding: 3px 6px; + border: 1px solid rgba(127, 29, 29, 0.35); + border-radius: 4px; + background: white; + color: #111827; + font: inherit; +} + +.lambda-boundary-row .diagram-port { + width: 18px; + height: 18px; + border: 1px solid #d4d4d4; + border-radius: 9999px; + cursor: pointer; + position: relative; + margin: 0; + pointer-events: all; +} diff --git a/src/NodeDev.Core/Class/NodeClassMethod.cs b/src/NodeDev.Core/Class/NodeClassMethod.cs index 016d7725..08051129 100644 --- a/src/NodeDev.Core/Class/NodeClassMethod.cs +++ b/src/NodeDev.Core/Class/NodeClassMethod.cs @@ -36,9 +36,9 @@ public NodeClassMethod(NodeClass ownerClass, string name, TypeBase returnType, b public bool HasReturnValue => ReturnType != Class.TypeFactory.Void; - public EntryNode? EntryNode => Graph.Nodes.Values.OfType().FirstOrDefault(); + public EntryNode? EntryNode => Graph.GetNodesInScope(null).OfType().FirstOrDefault(); - public IEnumerable ReturnNodes => Graph.Nodes.Values.OfType(); + public IEnumerable ReturnNodes => Graph.GetNodesInScope(null).OfType(); public GraphManagerService Manager => Graph.Manager; @@ -81,7 +81,7 @@ public void AddDefaultParameter() methodCall.OnNewMethodParameter(newParameter); } - var entry = Graph.Nodes.Values.OfType().FirstOrDefault(); + var entry = Graph.GetNodesInScope(null).OfType().FirstOrDefault(); entry?.AddNewParameter(newParameter); } diff --git a/src/NodeDev.Core/Class/NodeClassMethodParameter.cs b/src/NodeDev.Core/Class/NodeClassMethodParameter.cs index 40add4bf..f73c1934 100644 --- a/src/NodeDev.Core/Class/NodeClassMethodParameter.cs +++ b/src/NodeDev.Core/Class/NodeClassMethodParameter.cs @@ -44,8 +44,8 @@ public void SetIsOut(bool value) private void RefreshEntryAndReturnNodes() { - var entry = Method.Graph.Nodes.Values.OfType().First(); - var returnNodes = Method.Graph.Nodes.Values.OfType().ToList(); + var entry = Method.Graph.GetNodesInScope(null).OfType().First(); + var returnNodes = Method.Graph.GetNodesInScope(null).OfType().ToList(); entry.Refresh(); foreach (var returnNode in returnNodes) @@ -116,7 +116,7 @@ public void Rename(string name) methodCall.OnMethodParameterRenamed(oldName, this); } - var entry = Method.Graph.Nodes.Values.OfType().FirstOrDefault(); + var entry = Method.Graph.GetNodesInScope(null).OfType().FirstOrDefault(); entry?.RenameParameter(this, Method.Parameters.IndexOf(this)); } diff --git a/src/NodeDev.Core/CodeGeneration/GenerationContext.cs b/src/NodeDev.Core/CodeGeneration/GenerationContext.cs index f30e88da..19b625a5 100644 --- a/src/NodeDev.Core/CodeGeneration/GenerationContext.cs +++ b/src/NodeDev.Core/CodeGeneration/GenerationContext.cs @@ -12,36 +12,64 @@ namespace NodeDev.Core.CodeGeneration; ///
public class GenerationContext { + private sealed class SharedGenerationState(bool isDebug) + { + internal bool IsDebug { get; } = isDebug; + internal HashSet UsedVariableNames { get; } = []; + internal List BreakpointMappings { get; } = []; + internal List VariableMappings { get; } = []; + internal int UniqueCounter { get; set; } + internal int NextVirtualLine { get; set; } = 10000; + internal string? CurrentClassName { get; set; } + internal string? CurrentMethodName { get; set; } + } + + private readonly SharedGenerationState _sharedState; private readonly Dictionary _connectionToVariableName = new(); private readonly List _auxiliaryStatements = new(); - private readonly HashSet _usedVariableNames = new(); - private int _uniqueCounter = 0; - - // Track variable mappings for debugging - private readonly List _variableMappings = new(); - private string? _currentClassName; - private string? _currentMethodName; public GenerationContext(bool isDebug) { - IsDebug = isDebug; + _sharedState = new SharedGenerationState(isDebug); + } + + private GenerationContext(SharedGenerationState sharedState, string? callableScopeId) + { + _sharedState = sharedState; + CallableScopeId = callableScopeId; + } + + /// + /// The callable scope whose lexical symbols and auxiliary statements are held by + /// this context. Null identifies the containing method. + /// + public string? CallableScopeId { get; } + + /// + /// Creates a lexically isolated callable context while retaining method-wide name, + /// breakpoint, and debugging state. + /// + internal GenerationContext CreateChild(string callableScopeId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(callableScopeId); + return new GenerationContext(_sharedState, callableScopeId); } /// /// Whether to generate debug-friendly code (e.g., with event calls for stepping) /// - public bool IsDebug { get; } + public bool IsDebug => _sharedState.IsDebug; /// /// Collection of nodes with breakpoints and their line number mappings. /// This is populated during code generation to track where breakpoints should be set. /// - public List BreakpointMappings { get; } = new(); + public List BreakpointMappings => _sharedState.BreakpointMappings; /// /// Collection of connection-to-variable mappings for debugging. /// - public List VariableMappings => _variableMappings; + public List VariableMappings => _sharedState.VariableMappings; /// /// Sets the current class and method being generated. @@ -49,8 +77,8 @@ public GenerationContext(bool isDebug) /// public void SetCurrentMethod(string className, string methodName) { - _currentClassName = className; - _currentMethodName = methodName; + _sharedState.CurrentClassName = className; + _sharedState.CurrentMethodName = methodName; } /// @@ -70,17 +98,17 @@ public void RegisterVariableName(Connection connection, string variableName) _connectionToVariableName[connection.Id] = variableName; // Track this mapping for debugging (if we have method context) - if (_currentClassName != null && _currentMethodName != null) + if (_sharedState.CurrentClassName != null && _sharedState.CurrentMethodName != null) { // Note: SlotIndex will be -1 initially, as we don't know it until after compilation // It could be determined later by analyzing the PDB, but for now we'll use variable name lookup - _variableMappings.Add(new ConnectionVariableMapping + _sharedState.VariableMappings.Add(new ConnectionVariableMapping { ConnectionId = connection.Id, VariableName = variableName, SlotIndex = -1, // Unknown at code generation time - ClassName = _currentClassName, - MethodName = _currentMethodName + ClassName = _sharedState.CurrentClassName, + MethodName = _sharedState.CurrentMethodName }); } } @@ -94,19 +122,36 @@ public string GetUniqueName(string hint) var sanitized = SanitizeIdentifier(hint); // If the name is already unique, return it - if (_usedVariableNames.Add(sanitized)) + if (_sharedState.UsedVariableNames.Add(sanitized)) return sanitized; // Otherwise, append a counter until we find a unique name string uniqueName; do { - uniqueName = $"{sanitized}_{_uniqueCounter++}"; - } while (!_usedVariableNames.Add(uniqueName)); + uniqueName = $"{sanitized}_{_sharedState.UniqueCounter++}"; + } while (!_sharedState.UsedVariableNames.Add(uniqueName)); return uniqueName; } + /// + /// Allocates a method-wide unique virtual source line for a generated node. + /// Nested blocks and callable scopes intentionally share this allocator. + /// + internal int AllocateVirtualLine() + { + var line = _sharedState.NextVirtualLine; + _sharedState.NextVirtualLine += 1000; + return line; + } + + internal string CurrentClassName => _sharedState.CurrentClassName + ?? throw new InvalidOperationException("The current generated class has not been set."); + + internal string CurrentMethodName => _sharedState.CurrentMethodName + ?? throw new InvalidOperationException("The current generated method has not been set."); + /// /// Adds an auxiliary statement that needs to be emitted before the current operation /// diff --git a/src/NodeDev.Core/CodeGeneration/RoslynGraphBuilder.cs b/src/NodeDev.Core/CodeGeneration/RoslynGraphBuilder.cs index 07d6b961..5cccf525 100644 --- a/src/NodeDev.Core/CodeGeneration/RoslynGraphBuilder.cs +++ b/src/NodeDev.Core/CodeGeneration/RoslynGraphBuilder.cs @@ -4,6 +4,7 @@ using NodeDev.Core.Connections; using NodeDev.Core.Debugger; using NodeDev.Core.Nodes; +using NodeDev.Core.Nodes.Delegates; using NodeDev.Core.Nodes.Flow; using SF = Microsoft.CodeAnalysis.CSharp.SyntaxFactory; @@ -48,14 +49,21 @@ public RoslynGraphBuilder(Graph graph, GenerationContext context) public MethodDeclarationSyntax BuildMethod() { var method = _graph.SelfMethod; + _graph.ValidateCallableScopes(); // Set the current method in context for variable mapping string fullClassName = $"{_graph.SelfClass.Namespace}.{_graph.SelfClass.Name}"; _context.SetCurrentMethod(fullClassName, method.Name); - // Find the entry node - var entryNode = _graph.Nodes.Values.FirstOrDefault(x => x is EntryNode) - ?? throw new Exception($"No entry node found in graph {method.Name}"); + // Find the method entry only. Lambda entries and any corrupt method entry in a + // child callable scope must not become the containing method's start point. + var entryNodes = _graph.Nodes.Values + .OfType() + .Where(x => x.CallableScopeId == null) + .ToList(); + if (entryNodes.Count != 1) + throw new Exception($"Expected exactly one root entry node in graph {method.Name}, but found {entryNodes.Count}."); + var entryNode = entryNodes[0]; var entryOutput = entryNode.Outputs.FirstOrDefault() ?? throw new Exception("Entry node has no output"); @@ -69,37 +77,7 @@ public MethodDeclarationSyntax BuildMethod() _context.RegisterVariableName(output, output.Name); } - // Pre-declare variables for node outputs (similar to old CreateOutputsLocalVariableExpressions) - var variableDeclarations = new List(); - foreach (var node in _graph.Nodes.Values) - { - if (node.CanBeInlined) - continue; // inline nodes don't need pre-declared variables - - // Entry node parameters are not pre-declared, they are method parameters - if (node is EntryNode) - continue; - - foreach (var output in node.Outputs) - { - if (output.Type.IsExec) - continue; - - var varName = _context.GetUniqueName($"{node.Name}_{output.Name}"); - _context.RegisterVariableName(output, varName); - - // Declare: var = default(Type); - var typeSyntax = RoslynHelpers.GetTypeSyntax(output.Type); - var declarator = SF.VariableDeclarator(SF.Identifier(varName)) - .WithInitializer(SF.EqualsValueClause( - SF.DefaultExpression(typeSyntax))); - - variableDeclarations.Add( - SF.LocalDeclarationStatement( - SF.VariableDeclaration(SF.IdentifierName("var")) - .WithVariables(SF.SingletonSeparatedList(declarator)))); - } - } + var variableDeclarations = PredeclareOutputLocals(null, entryNode, _context); // Build the execution flow starting from entry var chunks = _graph.GetChunks(entryOutput, allowDeadEnd: false); @@ -145,41 +123,163 @@ public MethodDeclarationSyntax BuildMethod() } /// - /// Builds statements from node path chunks + /// Builds an explicitly typed, block-bodied lambda for a delegate creation node. + /// Capture snapshots are deliberately queued in the containing context, while + /// all body symbols and auxiliary statements live in a lexical child context. /// - internal List BuildStatements(Graph.NodePathChunks chunks) + internal ExpressionSyntax BuildLambdaExpression(CreateDelegateNode delegateNode) { - var statements = new List(); + ValidateNodeScope(delegateNode); - foreach (var chunk in chunks.Chunks) + var delegateType = delegateNode.DelegateType; + if (delegateType.HasUndefinedGenerics) { - var node = chunk.Input.Parent; - - // Resolve inputs first - foreach (var input in node.Inputs) - { - ResolveInputConnection(input); - } + throw new BuildError( + $"Delegate signature {delegateType.FriendlyName} contains unresolved generic types.", + delegateNode, + null); + } - // Get auxiliary statements generated during input resolution (like inline variable declarations) - // These need to be added BEFORE the main statement - statements.AddRange(_context.GetAndClearAuxiliaryStatements()); + var bodyNodes = _graph.Nodes.Values + .Where(x => x.CallableScopeId == delegateNode.BodyScopeId) + .ToList(); + var entries = bodyNodes.OfType().ToList(); + if (entries.Count != 1) + { + throw new BuildError( + $"Delegate {delegateNode.SignatureDisplayName} requires exactly one lambda entry, but found {entries.Count}.", + delegateNode, + null); + } - try - { - // Generate the statement for this node - var statement = node.GenerateRoslynStatement(chunk.SubChunk, _context); + if (bodyNodes.OfType().Any() || bodyNodes.OfType().Any()) + { + throw new BuildError( + $"Method entry and return nodes are not valid inside {delegateNode.SignatureDisplayName}.", + delegateNode, + null); + } - // Add the main statement - statements.Add(statement); - } - catch (Exception ex) when (ex is not BuildError) + if (delegateNode.Kind == DelegateKind.Action && bodyNodes.OfType().Any()) + throw new BuildError("An Action lambda cannot contain a lambda return node.", delegateNode, null); + if (delegateNode.Kind == DelegateKind.Func && bodyNodes.OfType().Any()) + throw new BuildError("A Func lambda cannot contain a lambda completion node.", delegateNode, null); + + var entry = entries[0]; + var expectedEntryOutputCount = 1 + delegateNode.Parameters.Count + delegateNode.Captures.Count; + if (entry.Outputs.Count != expectedEntryOutputCount || !entry.Outputs[0].Type.IsExec) + { + throw new BuildError( + $"Lambda entry ports do not match delegate signature {delegateNode.SignatureDisplayName}.", + entry, + null); + } + + var childContext = _context.CreateChild(delegateNode.BodyScopeId); + var lambdaParameters = new List(delegateNode.Parameters.Count); + for (var index = 0; index < delegateNode.Parameters.Count; index++) + { + var definition = delegateNode.Parameters[index]; + var parameterName = childContext.GetUniqueName(definition.Name); + childContext.RegisterVariableName(entry.Outputs[index + 1], parameterName); + lambdaParameters.Add( + SF.Parameter(SF.Identifier(parameterName)) + .WithType(RoslynHelpers.GetTypeSyntax(definition.Type))); + } + + for (var index = 0; index < delegateNode.Captures.Count; index++) + { + var capture = delegateNode.Captures[index]; + var captureInput = delegateNode.CaptureInputs[index]; + ResolveInputConnection(captureInput); + var outerVariableName = _context.GetVariableName(captureInput) + ?? throw new BuildError($"Unable to resolve capture {capture.Name}.", delegateNode, null); + + var snapshotName = _context.GetUniqueName($"lambdaCapture_{capture.Name}"); + var snapshotDeclarator = SF.VariableDeclarator(SF.Identifier(snapshotName)) + .WithInitializer(SF.EqualsValueClause(SF.IdentifierName(outerVariableName))); + _context.AddAuxiliaryStatement( + SF.LocalDeclarationStatement( + SF.VariableDeclaration(SF.IdentifierName("var")) + .WithVariables(SF.SingletonSeparatedList(snapshotDeclarator)))); + + var entryOutputIndex = 1 + delegateNode.Parameters.Count + index; + childContext.RegisterVariableName(entry.Outputs[entryOutputIndex], snapshotName); + } + + var childBuilder = new RoslynGraphBuilder(_graph, childContext); + var body = childBuilder.BuildCallableBody(delegateNode.BodyScopeId, entry); + var lambda = SF.ParenthesizedLambdaExpression() + .WithParameterList(SF.ParameterList(SF.SeparatedList(lambdaParameters))) + .WithBlock(body); + + return SF.CastExpression( + RoslynHelpers.GetExactDelegateTypeSyntax(delegateType), + SF.ParenthesizedExpression(lambda)); + } + + /// + /// Builds the statements and local declarations for one non-method callable body. + /// + internal BlockSyntax BuildCallableBody(string callableScopeId, LambdaEntryNode entryNode) + { + if (_context.CallableScopeId != callableScopeId || entryNode.CallableScopeId != callableScopeId) + { + throw new BuildError( + $"Lambda entry {entryNode.Name} does not belong to callable scope '{callableScopeId}'.", + entryNode, + null); + } + + var entryOutput = entryNode.Outputs.SingleOrDefault(x => x.Type.IsExec) + ?? throw new BuildError("Lambda entry has no execution output.", entryNode, null); + var variableDeclarations = PredeclareOutputLocals(callableScopeId, entryNode, _context); + var chunks = _graph.GetChunks(entryOutput, allowDeadEnd: false); + var bodyStatements = BuildStatements(chunks); + + return SF.Block(variableDeclarations.Cast().Concat(bodyStatements)); + } + + private List PredeclareOutputLocals( + string? callableScopeId, + Node entryNode, + GenerationContext context) + { + var variableDeclarations = new List(); + foreach (var node in _graph.Nodes.Values.Where(x => x.CallableScopeId == callableScopeId)) + { + if (node.CanBeInlined || node == entryNode) + continue; + + foreach (var output in node.Outputs.Where(x => !x.Type.IsExec)) { - throw new BuildError($"Failed to generate statement for node type {node.GetType().Name}: {ex.Message}", node, ex); + var varName = context.GetUniqueName($"{node.Name}_{output.Name}"); + context.RegisterVariableName(output, varName); + + var declarator = SF.VariableDeclarator(SF.Identifier(varName)) + .WithInitializer(SF.EqualsValueClause( + SF.DefaultExpression(RoslynHelpers.GetTypeSyntax(output.Type)))); + + variableDeclarations.Add( + SF.LocalDeclarationStatement( + SF.VariableDeclaration(SF.IdentifierName("var")) + .WithVariables(SF.SingletonSeparatedList(declarator)))); } } - return statements; + return variableDeclarations; + } + + /// + /// Builds statements from node path chunks + /// + internal List BuildStatements(Graph.NodePathChunks chunks) + { + return BuildStatementsCore( + chunks, + _context.IsDebug, + _context.IsDebug ? _context.CurrentClassName : null, + _context.IsDebug ? _context.CurrentMethodName : null); } /// @@ -187,14 +287,27 @@ internal List BuildStatements(Graph.NodePathChunks chunks) /// Returns the statements and populates breakpoint info in the context. /// internal List BuildStatementsWithBreakpointTracking(Graph.NodePathChunks chunks, string className, string methodName) + { + _context.SetCurrentMethod(className, methodName); + return BuildStatementsCore(chunks, true, className, methodName); + } + + private List BuildStatementsCore( + Graph.NodePathChunks chunks, + bool trackBreakpoints, + string? className, + string? methodName) { var statements = new List(); - string virtualFileName = $"NodeDev_{className}_{methodName}.g.cs"; - int nodeExecutionOrder = 0; // Track execution order of ALL nodes + ValidateNodeScope(chunks.OutputStartPoint.Parent); + var virtualFileName = trackBreakpoints + ? $"NodeDev_{className}_{methodName}.g.cs" + : null; foreach (var chunk in chunks.Chunks) { var node = chunk.Input.Parent; + ValidateNodeScope(node); // Resolve inputs first foreach (var input in node.Inputs) @@ -209,47 +322,40 @@ internal List BuildStatementsWithBreakpointTracking(Graph.NodeP try { + // Allocate before recursively generating compound statements so parent and + // nested nodes share one stable, method-wide sequence. + var nodeVirtualLine = trackBreakpoints + ? _context.AllocateVirtualLine() + : 0; + // Generate the statement for this node var statement = node.GenerateRoslynStatement(chunk.SubChunk, _context); - // In debug builds, ALWAYS add #line directive for every node (not just those with breakpoints) - // This allows breakpoints to be set dynamically during debugging - // Create a #line directive that maps this statement to a unique virtual line - // The virtual line encodes the node's execution order: 10000 + (order * 1000) - int nodeVirtualLine = 10000 + (nodeExecutionOrder * 1000); - - // Format: #line 10000 "virtual_file.cs" - var lineDirective = SF.Trivia( - SF.LineDirectiveTrivia( - SF.Token(SyntaxKind.HashToken), - SF.Token(SyntaxKind.LineKeyword), - SF.Literal(nodeVirtualLine), - SF.Literal($"\"{virtualFileName}\"", virtualFileName), // Quoted filename - SF.Token(SyntaxKind.EndOfDirectiveToken), - true - ) - ); - - // Add the #line directive before the statement - statement = statement.WithLeadingTrivia(lineDirective); - - // Record the mapping for this node (regardless of whether it currently has a breakpoint) - // This allows breakpoints to be added dynamically after build - _context.BreakpointMappings.Add(new NodeDev.Core.Debugger.NodeBreakpointInfo + if (trackBreakpoints) { - NodeId = node.Id, - NodeName = node.Name, - ClassName = className, - MethodName = methodName, - LineNumber = nodeVirtualLine, - SourceFile = virtualFileName - }); + var lineDirective = SF.Trivia( + SF.LineDirectiveTrivia( + SF.Token(SyntaxKind.HashToken), + SF.Token(SyntaxKind.LineKeyword), + SF.Literal(nodeVirtualLine), + SF.Literal($"\"{virtualFileName}\"", virtualFileName!), + SF.Token(SyntaxKind.EndOfDirectiveToken), + true)); + + statement = statement.WithLeadingTrivia(lineDirective); + _context.BreakpointMappings.Add(new NodeBreakpointInfo + { + NodeId = node.Id, + NodeName = node.Name, + ClassName = className!, + MethodName = methodName!, + LineNumber = nodeVirtualLine, + SourceFile = virtualFileName! + }); + } // Add the main statement statements.Add(statement); - - // Increment execution order for next node - nodeExecutionOrder++; } catch (Exception ex) when (ex is not BuildError) { @@ -259,16 +365,16 @@ internal List BuildStatementsWithBreakpointTracking(Graph.NodeP return statements; } - - /// - /// Counts the number of lines a statement will take when normalized. - /// This is a rough estimate used for line number tracking. - /// - private static int CountStatementLines(StatementSyntax statement) + + private void ValidateNodeScope(Node node) { - // Count the number of line breaks in the statement text - var text = statement.NormalizeWhitespace().ToFullString(); - return text.Split('\n').Length; + if (node.CallableScopeId != _context.CallableScopeId) + { + throw new BuildError( + $"Node {node.Name} belongs to callable scope '{node.CallableScopeId ?? "method"}' but was reached while building '{_context.CallableScopeId ?? "method"}'.", + node, + null); + } } /// @@ -276,6 +382,8 @@ private static int CountStatementLines(StatementSyntax statement) /// private void ResolveInputConnection(Connection input) { + ValidateNodeScope(input.Parent); + if (input.Type.IsExec) return; @@ -337,6 +445,7 @@ private void ResolveInputConnection(Connection input) { var outputConnection = input.Connections[0]; var otherNode = outputConnection.Parent; + ValidateNodeScope(otherNode); if (otherNode.CanBeInlined) { @@ -415,15 +524,8 @@ public ExpressionSyntax GetInputExpression(Connection input, GenerationContext c var varName = context.GetVariableName(input); - // If not found, check if it's a method parameter if (varName == null) - { - var param = _graph.SelfMethod.Parameters.FirstOrDefault(p => p.Name == input.Name); - if (param != null) - return SF.IdentifierName(param.Name); - throw new Exception($"Variable name not found for connection {input.Name} of node {input.Parent.Name}"); - } return SF.IdentifierName(varName); } diff --git a/src/NodeDev.Core/CodeGeneration/SyntaxHelper.cs b/src/NodeDev.Core/CodeGeneration/SyntaxHelper.cs index 552d3e71..5bf673d6 100644 --- a/src/NodeDev.Core/CodeGeneration/SyntaxHelper.cs +++ b/src/NodeDev.Core/CodeGeneration/SyntaxHelper.cs @@ -32,4 +32,29 @@ internal static TypeSyntax GetTypeSyntax(TypeBase type) // Parse the type name - handles generics like "List" return SF.ParseTypeName(typeName); } + + /// + /// Creates a globally-qualified syntax node for a supported BCL Action or Func + /// type. Lambda casts use this form so a project type named Action or Func cannot + /// shadow the intended delegate family. + /// + internal static TypeSyntax GetExactDelegateTypeSyntax(TypeBase delegateType) + { + if (delegateType is not RealType realType) + throw new ArgumentException($"Delegate type must be a real BCL type, but was {delegateType.FriendlyName}.", nameof(delegateType)); + + var backendType = realType.BackendType; + var typeName = backendType.Name.Split('`')[0]; + if (backendType.Namespace != "System" || (typeName != nameof(Action) && typeName != "Func")) + throw new ArgumentException($"Unsupported delegate type {delegateType.FriendlyName}.", nameof(delegateType)); + + SimpleNameSyntax simpleName = realType.Generics.Length == 0 + ? SF.IdentifierName(typeName) + : SF.GenericName(SF.Identifier(typeName)) + .WithTypeArgumentList( + SF.TypeArgumentList( + SF.SeparatedList(realType.Generics.Select(GetTypeSyntax)))); + + return SF.QualifiedName(SF.ParseName("global::System"), simpleName); + } } diff --git a/src/NodeDev.Core/Connections/Connection.cs b/src/NodeDev.Core/Connections/Connection.cs index 9e4db4a8..016ab16b 100644 --- a/src/NodeDev.Core/Connections/Connection.cs +++ b/src/NodeDev.Core/Connections/Connection.cs @@ -77,8 +77,12 @@ internal static Connection Deserialize(Node parent, SerializedConnection seriali { // Find the LinkedExec connection, if any Connection? linkedExec = null; - if (linkedExec != null) - linkedExec = parent.Graph.Nodes.SelectMany(x => x.Value.InputsAndOutputs).FirstOrDefault(x => x.Id == serializedConnectionObj.LinkedExec); + if (serializedConnectionObj.LinkedExec != null) + { + linkedExec = parent.InputsAndOutputs + .Concat(parent.Graph.Nodes.SelectMany(x => x.Value.InputsAndOutputs)) + .FirstOrDefault(x => x.Id == serializedConnectionObj.LinkedExec); + } var type = TypeBase.Deserialize(parent.TypeFactory, serializedConnectionObj.SerializedType); var connection = new Connection(serializedConnectionObj.Name, parent, type, serializedConnectionObj.Id, linkedExec); diff --git a/src/NodeDev.Core/Graph.cs b/src/NodeDev.Core/Graph.cs index ec53f5cf..523adb4d 100644 --- a/src/NodeDev.Core/Graph.cs +++ b/src/NodeDev.Core/Graph.cs @@ -2,6 +2,7 @@ using NodeDev.Core.Connections; using NodeDev.Core.ManagerServices; using NodeDev.Core.Nodes; +using NodeDev.Core.Nodes.Delegates; using NodeDev.Core.Nodes.Flow; using System.Linq.Expressions; @@ -43,6 +44,38 @@ static Graph() NodeProvider.Initialize(); } + public IEnumerable GetNodesInScope(string? scopeId) => Nodes.Values.Where(x => x.CallableScopeId == scopeId); + + public CreateDelegateNode? GetOwningLambda(string? scopeId) + { + if (scopeId == null) + return null; + return Nodes.TryGetValue(scopeId, out var owner) ? owner as CreateDelegateNode : null; + } + + public bool IsRootScope(string? scopeId) => scopeId == null; + + public bool IsScopeAncestorOf(string? ancestorScopeId, string? descendantScopeId) + { + if (ancestorScopeId == descendantScopeId) + return false; + + var currentScopeId = descendantScopeId; + var visitedScopes = new HashSet(); + while (currentScopeId != null && visitedScopes.Add(currentScopeId)) + { + var owner = GetOwningLambda(currentScopeId); + if (owner == null) + return false; + + currentScopeId = owner.CallableScopeId; + if (currentScopeId == ancestorScopeId) + return true; + } + + return false; + } + public void RaiseGraphChanged(bool requireUIRefresh) => Project.GraphChangedSubject.OnNext((this, requireUIRefresh)); @@ -115,7 +148,11 @@ internal bool ContainOutput(Connection output) /// if "e" was to be merging with another path, we'd stop at "e" and return it as a merging point. /// internal NodePathChunks GetChunks(Connection execOutput, bool allowDeadEnd) + => GetChunks(execOutput, allowDeadEnd, execOutput.Parent.CallableScopeId); + + private NodePathChunks GetChunks(Connection execOutput, bool allowDeadEnd, string? expectedScopeId) { + ValidateNodeScope(execOutput.Parent, expectedScopeId); var chunks = new List(); var currentInput = execOutput.Connections.FirstOrDefault(); @@ -129,6 +166,7 @@ internal NodePathChunks GetChunks(Connection execOutput, bool allowDeadEnd) while (true) { + ValidateNodeScope(currentInput.Parent, expectedScopeId); if (currentInput.Parent.Outputs.Count(x => x.Type.IsExec) <= 1) // we can keep adding to the straight path. It's either a dead end or the path keeps going { if (currentInput.Connections.Count != 1) @@ -182,7 +220,7 @@ internal NodePathChunks GetChunks(Connection execOutput, bool allowDeadEnd) else // we have a subchunk { // Get all the chunks of the node (example, both "c" and "d" in the example above) - var subChunk = GetChunks(currentInput, currentInput.Parent, allowDeadEnd); + var subChunk = GetChunks(currentInput, currentInput.Parent, allowDeadEnd, expectedScopeId); if (subChunk.Count == 0) return new NodePathChunks(execOutput, chunks, null, [currentInput]); // we reached a dead end @@ -233,15 +271,16 @@ internal NodePathChunks GetChunks(Connection execOutput, bool allowDeadEnd) /// Does a parent allow dead end here. /// /// - private Dictionary GetChunks(Connection input, Node node, bool allowDeadEnd) + private Dictionary GetChunks(Connection input, Node node, bool allowDeadEnd, string? expectedScopeId) { + ValidateNodeScope(node, expectedScopeId); var chunks = new Dictionary(); foreach (var output in node.Outputs.Where(x => x.Type.IsExec)) { // allowDeadEnd is prioritized over the node's own setting, since we can have a dead end if the parent allows it. // Cases like a "branch" inside a loop can be a dead end, even though branch doesn't allow it, because the loop does. - var chunk = GetChunks(output, allowDeadEnd || node.DoesOutputPathAllowDeadEnd(output)); + var chunk = GetChunks(output, allowDeadEnd || node.DoesOutputPathAllowDeadEnd(output), expectedScopeId); chunks[output] = chunk; // Validate if the chunk is a merge and if it is allowed @@ -283,6 +322,12 @@ private Dictionary GetChunks(Connection input, Node return chunks; } + private static void ValidateNodeScope(Node node, string? expectedScopeId) + { + if (node.CallableScopeId != expectedScopeId) + throw new InvalidOperationException($"Execution flow crosses callable scope at node '{node.Name}' ({node.Id})."); + } + #endregion #region BuildExpression @@ -439,7 +484,94 @@ internal static void Deserialize(SerializedGraph serializedGraphObj, Graph graph var manager = new GraphManagerService(noUi); manager.AddNode(node); } + + foreach (var node in graph.Nodes.Values) + node.FinalizeDeserialization(); + + graph.ValidateCallableScopes(); + } + + public void ValidateCallableScopes() + { + foreach (var node in Nodes.Values) + { + if (node.CallableScopeId != null) + { + var owner = GetOwningLambda(node.CallableScopeId) + ?? throw new InvalidOperationException($"Node '{node.Name}' ({node.Id}) belongs to orphaned callable scope '{node.CallableScopeId}'."); + if (owner == node) + throw new InvalidOperationException($"Delegate node '{node.Name}' cannot belong to its own body scope."); + } + + if ((node is EntryNode || node is ReturnNode) && node.CallableScopeId != null) + throw new InvalidOperationException($"Method node '{node.Name}' ({node.Id}) cannot be placed inside a lambda scope."); + if (node is LambdaEntryNode or LambdaReturnNode or LambdaCompleteNode && node.CallableScopeId == null) + throw new InvalidOperationException($"Lambda node '{node.Name}' ({node.Id}) cannot be placed in the root method scope."); + + foreach (var connection in node.InputsAndOutputs) + { + foreach (var other in connection.Connections) + { + if (node.CallableScopeId != other.Parent.CallableScopeId) + throw new InvalidOperationException($"Connection from '{node.Name}.{connection.Name}' to '{other.Parent.Name}.{other.Name}' crosses a callable scope boundary."); + } + } + } + + foreach (var owner in Nodes.Values.OfType()) + { + if (owner.Parameters.Count > BclDelegateType.MaximumParameterCount) + throw new InvalidOperationException($"Delegate '{owner.SignatureDisplayName}' has too many parameters."); + if (owner.Kind == DelegateKind.Func && owner.ResultType == null) + throw new InvalidOperationException($"Func delegate '{owner.Id}' has no result type."); + + var bodyNodes = GetNodesInScope(owner.BodyScopeId).ToList(); + var entries = bodyNodes.OfType().ToList(); + if (entries.Count != 1) + throw new InvalidOperationException($"Delegate '{owner.SignatureDisplayName}' requires exactly one lambda entry, but found {entries.Count}."); + + if (owner.CaptureInputs.Count != owner.Captures.Count || entries[0].CaptureOutputs.Count != owner.Captures.Count) + throw new InvalidOperationException($"Delegate '{owner.SignatureDisplayName}' has inconsistent capture ports."); + if (entries[0].ParameterOutputs.Count != owner.Parameters.Count) + throw new InvalidOperationException($"Delegate '{owner.SignatureDisplayName}' has inconsistent parameter ports."); + for (var index = 0; index < owner.Parameters.Count; index++) + { + var definition = owner.Parameters[index]; + var output = entries[0].ParameterOutputs[index]; + if (output.Name != definition.Name || output.Type != definition.Type) + throw new InvalidOperationException($"Delegate '{owner.SignatureDisplayName}' parameter '{definition.Name}' disagrees with its lambda entry port."); + } + for (var index = 0; index < owner.Captures.Count; index++) + { + var definition = owner.Captures[index]; + var input = owner.CaptureInputs[index]; + var output = entries[0].CaptureOutputs[index]; + if (input.Name != definition.Name || input.Type != definition.Type || output.Name != $"Captured {definition.Name}" || output.Type != definition.Type) + throw new InvalidOperationException($"Delegate '{owner.SignatureDisplayName}' capture '{definition.Name}' has inconsistent boundary ports."); + } + + if (owner.Kind == DelegateKind.Func) + { + if (bodyNodes.OfType().Any()) + throw new InvalidOperationException($"Func delegate '{owner.SignatureDisplayName}' contains an Action completion node."); + if (bodyNodes.OfType().Count(x => x.IsImplicit) > 1) + throw new InvalidOperationException($"Func delegate '{owner.SignatureDisplayName}' contains more than one implicit return node."); + if (!bodyNodes.OfType().Any()) + throw new InvalidOperationException($"Func delegate '{owner.SignatureDisplayName}' has no return node."); + if (bodyNodes.OfType().Any(x => x.ResultInput.Type != owner.ResultType)) + throw new InvalidOperationException($"Func delegate '{owner.SignatureDisplayName}' contains a return port with the wrong result type."); + } + else + { + if (bodyNodes.OfType().Any()) + throw new InvalidOperationException($"Action delegate '{owner.SignatureDisplayName}' contains a Func return node."); + if (!bodyNodes.OfType().Any()) + throw new InvalidOperationException($"Action delegate '{owner.SignatureDisplayName}' has no completion node."); + } + + _ = GetChunks(entries[0].ExecOutput, allowDeadEnd: false); + } } #endregion -} \ No newline at end of file +} diff --git a/src/NodeDev.Core/ManagerServices/GraphManagerService.cs b/src/NodeDev.Core/ManagerServices/GraphManagerService.cs index 3719905d..d2993c8c 100644 --- a/src/NodeDev.Core/ManagerServices/GraphManagerService.cs +++ b/src/NodeDev.Core/ManagerServices/GraphManagerService.cs @@ -1,5 +1,7 @@ using NodeDev.Core.Connections; using NodeDev.Core.Nodes; +using NodeDev.Core.Nodes.Delegates; +using NodeDev.Core.Nodes.Flow; using NodeDev.Core.Types; namespace NodeDev.Core.ManagerServices; @@ -28,12 +30,20 @@ internal GraphManagerService(IGraphCanvas graphCanvas) /// /// public Node AddNode(NodeProvider.NodeSearchResult searchResult, Action populateNode) + => AddNode(searchResult, populateNode, callableScopeId: null); + + public Node AddNode(NodeProvider.NodeSearchResult searchResult, Action populateNode, string? callableScopeId) { var node = (Node)Activator.CreateInstance(searchResult.Type, [Graph, null])!; - populateNode(node); + node.CallableScopeId = callableScopeId; - // add it to the nodes and the UI - AddNode(node); + if (searchResult is NodeProvider.DelegateCreationNode delegateCreation && node is CreateDelegateNode createDelegate) + createDelegate.InitializeFromDelegateType(delegateCreation.DelegateType); + else if (searchResult is NodeProvider.DelegateInvocationNode delegateInvocation && node is InvokeDelegateNode invokeDelegate) + invokeDelegate.InitializeFromDelegateType(delegateInvocation.DelegateType); + + ValidateNodePlacement(node); + populateNode(node); if (searchResult is NodeProvider.MethodCallNode methodCall && node is MethodCall methodCallNode) methodCallNode.SetMethodTarget(methodCall.MethodInfo); @@ -42,11 +52,32 @@ public Node AddNode(NodeProvider.NodeSearchResult searchResult, Action pop else if (searchResult is NodeProvider.SetPropertyOrFieldNode setPropertyOrField && node is SetPropertyOrField setPropertyOrFieldNode) setPropertyOrFieldNode.SetMemberTarget(setPropertyOrField.MemberInfo); + // Add the node only after search-result-specific initialization has created all + // of its connections. UI canvases snapshot those connections in AddNode. + AddNode(node); + + if (node is CreateDelegateNode delegateNode) + CreateDefaultDelegateBody(delegateNode); + return node; } + public void AddNode(Node node, string? callableScopeId) + { + node.CallableScopeId = callableScopeId; + ValidateNodePlacement(node); + AddNode(node); + } + + public void AddDelegateNode(CreateDelegateNode node, string? callableScopeId) + { + AddNode(node, callableScopeId); + CreateDefaultDelegateBody(node); + } + public void AddNode(Node node) { + ValidateNodePlacement(node); ((IDictionary)Graph.Nodes)[node.Id] = node; GraphCanvas.AddNode(node); @@ -55,40 +86,182 @@ public void AddNode(Node node) public void RemoveNode(Node node) { - Graph._Nodes.Remove(node.Id); + var removalOrder = GetRecursiveRemovalOrder(node); + var disconnectedPairs = new HashSet<(string, string)>(); - GraphCanvas.RemoveNode(node); + foreach (var removedNode in removalOrder) + { + foreach (var connection in removedNode.InputsAndOutputs) + { + foreach (var other in connection.Connections.ToList()) + { + var pair = string.CompareOrdinal(connection.Id, other.Id) < 0 ? (connection.Id, other.Id) : (other.Id, connection.Id); + if (disconnectedPairs.Add(pair)) + DisconnectConnectionBetween(connection, other); + } + } + } + + foreach (var removedNode in removalOrder) + { + Graph._Nodes.Remove(removedNode.Id); + GraphCanvas.RemoveNode(removedNode); + } Graph.RaiseGraphChanged(false); } + private List GetRecursiveRemovalOrder(Node root) + { + var result = new List(); + var visited = new HashSet(); + + void Visit(Node node) + { + if (!visited.Add(node)) + return; + if (node is CreateDelegateNode owner) + { + foreach (var child in Graph.GetNodesInScope(owner.BodyScopeId).ToList()) + Visit(child); + } + result.Add(node); + } + + Visit(root); + return result; + } + + private void CreateDefaultDelegateBody(CreateDelegateNode owner) + { + var entry = new LambdaEntryNode(Graph) { CallableScopeId = owner.BodyScopeId }; + Node terminal = owner.Kind == DelegateKind.Func + ? new LambdaReturnNode(Graph) { CallableScopeId = owner.BodyScopeId, IsImplicit = true } + : new LambdaCompleteNode(Graph) { CallableScopeId = owner.BodyScopeId }; + + entry.RefreshFromOwner(owner); + if (terminal is LambdaReturnNode lambdaReturn) + lambdaReturn.RefreshFromOwner(owner); + + AddNode(entry); + AddNode(terminal); + AddNewConnectionBetween(entry.ExecOutput, terminal.Inputs[0]); + } + + private void ValidateNodePlacement(Node node) + { + if ((node is EntryNode || node is ReturnNode) && node.CallableScopeId != null) + throw new InvalidOperationException("Method entry and return nodes can only be added to the root method scope."); + if (node is LambdaEntryNode or LambdaReturnNode or LambdaCompleteNode) + { + var owner = Graph.GetOwningLambda(node.CallableScopeId) + ?? throw new InvalidOperationException("Lambda entry and terminal nodes require a valid owning lambda scope."); + if (node is LambdaReturnNode && owner.Kind != DelegateKind.Func) + throw new InvalidOperationException("Lambda return nodes can only be added to Func scopes."); + if (node is LambdaCompleteNode && owner.Kind != DelegateKind.Action) + throw new InvalidOperationException("Lambda completion nodes can only be added to Action scopes."); + } + else if (node.CallableScopeId != null && Graph.GetOwningLambda(node.CallableScopeId) == null) + throw new InvalidOperationException($"Cannot add a node to orphaned callable scope '{node.CallableScopeId}'."); + } + #endregion #region Connections + /// + /// Connects ports normally when they share a scope. When a data value flows from + /// an enclosing scope into a lambda, creates and wires the required captures. + /// + public void AddNewConnectionBetweenOrCapture(Connection source, Connection destination) + { + if (source.IsInput) + (destination, source) = (source, destination); + + if (source.Parent.CallableScopeId == destination.Parent.CallableScopeId) + { + AddNewConnectionBetween(source, destination); + return; + } + + if (!source.IsOutput || !destination.IsInput) + throw new InvalidOperationException("Graph connections must connect an output port to an input port."); + if (source.Parent.Graph != Graph || destination.Parent.Graph != Graph) + throw new InvalidOperationException("Cannot connect ports from another graph."); + if (source.Type.IsExec || destination.Type.IsExec) + throw new InvalidOperationException("Execution flow cannot be captured across a callable scope boundary."); + if (!source.IsAssignableTo(destination, true, true, out _, out _, out _)) + throw new InvalidOperationException($"Cannot assign '{source.Type.FriendlyName}' to '{destination.Type.FriendlyName}'."); + + var owners = GetAutomaticCaptureRoute(source.Parent.CallableScopeId, destination.Parent.CallableScopeId); + var captureName = GetAutomaticCaptureName(source); + var scopedSource = source; + + foreach (var owner in owners) + { + owner.AddCapture(captureName, scopedSource.Type); + GraphCanvas.Refresh(owner); + + var captureIndex = owner.Captures.Count - 1; + var entry = Graph.GetNodesInScope(owner.BodyScopeId).OfType().Single(); + AddNewConnectionBetween(scopedSource, owner.CaptureInputs[captureIndex]); + scopedSource = entry.CaptureOutputs[captureIndex]; + } + + AddNewConnectionBetween(scopedSource, destination); + } + + private List GetAutomaticCaptureRoute(string? sourceScopeId, string? destinationScopeId) + { + var owners = new List(); + var currentScopeId = destinationScopeId; + + while (currentScopeId != sourceScopeId) + { + var owner = Graph.GetOwningLambda(currentScopeId) + ?? throw new InvalidOperationException("Automatic captures can only connect a value from an enclosing scope into a nested lambda scope."); + owners.Add(owner); + currentScopeId = owner.CallableScopeId; + } + + owners.Reverse(); + return owners; + } + + private static string GetAutomaticCaptureName(Connection source) + { + const string capturedPrefix = "Captured "; + var name = source.Name; + if (name.StartsWith(capturedPrefix, StringComparison.OrdinalIgnoreCase)) + name = name[capturedPrefix.Length..]; + return string.IsNullOrWhiteSpace(name) ? "capture" : name; + } + public void MergeRemovedConnectionsWithNewConnections(IEnumerable newConnections, IEnumerable removedConnections) { - foreach (var removedConnection in removedConnections) + var newConnectionsList = newConnections.ToList(); + var removedConnectionsList = removedConnections.ToList(); + var reconnections = new List<(Connection OldLink, Connection NewConnection)>(); + + foreach (var removedConnection in removedConnectionsList) { - var newConnection = newConnections.FirstOrDefault(x => x.Parent == removedConnection.Parent && x.Name == removedConnection.Name && x.Type == removedConnection.Type); + var newConnection = newConnectionsList.FirstOrDefault(x => x.Parent == removedConnection.Parent && x.Name == removedConnection.Name && x.Type == removedConnection.Type); - // if we found a new connection, connect them together and remove the old connection - foreach (var oldLink in removedConnection.Connections) + foreach (var oldLink in removedConnection.Connections.ToList()) { DisconnectConnectionBetween(oldLink, removedConnection); // cleanup the old connection - if (newConnection != null) - { - // Before we re-connect them let's make sure both are inputs or both outputs - if (oldLink.IsInput != newConnection.IsInput) - { - // we can safely reconnect the new connection to the old link - // Either newConnection is an input and removedConnection is an output or vice versa - AddNewConnectionBetween(oldLink, newConnection); - } - } + // Reconnect only after the canvas has ports for the replacement connection. + if (newConnection != null && oldLink.IsInput != newConnection.IsInput) + reconnections.Add((oldLink, newConnection)); } } + + foreach (var parent in newConnectionsList.Concat(removedConnectionsList).Select(connection => connection.Parent).Distinct()) + GraphCanvas.Refresh(parent); + + foreach (var (oldLink, newConnection) in reconnections) + AddNewConnectionBetween(oldLink, newConnection); } public void AddNewConnectionBetween(Connection source, Connection destination) @@ -98,6 +271,13 @@ public void AddNewConnectionBetween(Connection source, Connection destination) (destination, source) = (source, destination); } + if (!source.IsOutput || !destination.IsInput) + throw new InvalidOperationException("Graph connections must connect an output port to an input port."); + if (source.Parent.Graph != Graph || destination.Parent.Graph != Graph) + throw new InvalidOperationException("Cannot connect ports from another graph."); + if (source.Parent.CallableScopeId != destination.Parent.CallableScopeId) + throw new InvalidOperationException($"Cannot connect '{source.Parent.Name}.{source.Name}' to '{destination.Parent.Name}.{destination.Name}' across a callable scope boundary. Add an explicit lambda capture instead."); + if (!source._Connections.Contains(destination)) source._Connections.Add(destination); if (!destination._Connections.Contains(source)) @@ -153,7 +333,7 @@ public void PropagateNewGeneric(Node node, IReadOnlyDictionary node.OnBeforeGenericTypeDefined(changedGenerics); bool hadAnyChanges = false; - foreach (var port in node.InputsAndOutputs) // check if any of the ports have the generic we just solved + foreach (var port in node.InputsAndOutputs.ToList()) // check if any of the ports have the generic we just solved { var previousType = useInitialTypes ? port.InitialType : port.Type; diff --git a/src/NodeDev.Core/NodeProvider.cs b/src/NodeDev.Core/NodeProvider.cs index c3e84319..bd86cdcc 100644 --- a/src/NodeDev.Core/NodeProvider.cs +++ b/src/NodeDev.Core/NodeProvider.cs @@ -1,6 +1,8 @@ using NodeDev.Core.Connections; using NodeDev.Core.Nodes; +using NodeDev.Core.Nodes.Delegates; using NodeDev.Core.Types; +using System.Collections.Concurrent; using System.Reflection; using System.Runtime.CompilerServices; @@ -34,7 +36,53 @@ public record class NodeSearchResult(Type Type); public record class MethodCallNode(Type Type, IMethodInfo MethodInfo) : NodeSearchResult(Type); public record class GetPropertyOrFieldNode(Type Type, IMemberInfo MemberInfo) : NodeSearchResult(Type); public record class SetPropertyOrFieldNode(Type Type, IMemberInfo MemberInfo) : NodeSearchResult(Type); + public record class DelegateCreationNode(Type Type, TypeBase DelegateType) : NodeSearchResult(Type); + public record class DelegateInvocationNode(Type Type, TypeBase DelegateType) : NodeSearchResult(Type); public static IEnumerable Search(Project project, string text, Connection? startConnection) + => SearchCore(project, text, startConnection).Where(x => IsAvailableInScope(x.Type, null, null)); + + public static IEnumerable Search(Graph graph, string text, Connection? startConnection, string? callableScopeId) + { + var results = SearchCore(graph.Project, text, startConnection); + var owner = graph.GetOwningLambda(callableScopeId); + + results = results.Where(result => IsAvailableInScope(result.Type, callableScopeId, owner)); + + if (startConnection != null && BclDelegateType.TryDescribe(startConnection.Type, out var kind, out _, out _)) + { + results = results.Select(result => + { + if (startConnection.IsInput && + ((kind == DelegateKind.Action && result.Type == typeof(CreateActionNode)) || + (kind == DelegateKind.Func && result.Type == typeof(CreateFuncNode)))) + return (NodeSearchResult)new DelegateCreationNode(result.Type, startConnection.Type); + if (startConnection.IsOutput && result.Type == typeof(InvokeDelegateNode)) + return new DelegateInvocationNode(result.Type, startConnection.Type); + return result; + }); + } + + return results; + } + + private static bool IsAvailableInScope(Type nodeType, string? callableScopeId, CreateDelegateNode? owner) + { + if (nodeType == typeof(LambdaEntryNode)) + return false; + if (callableScopeId == null) + return nodeType != typeof(LambdaReturnNode) && nodeType != typeof(LambdaCompleteNode); + if (owner == null) + return false; + if (nodeType == typeof(Nodes.Flow.EntryNode) || nodeType == typeof(Nodes.Flow.ReturnNode)) + return false; + if (nodeType == typeof(LambdaReturnNode)) + return owner.Kind == DelegateKind.Func; + if (nodeType == typeof(LambdaCompleteNode)) + return owner.Kind == DelegateKind.Action; + return true; + } + + private static IEnumerable SearchCore(Project project, string text, Connection? startConnection) { if (startConnection?.Type is UndefinedGenericType) startConnection = null; // we want to list every possible choices @@ -86,7 +134,7 @@ IEnumerable GetPropertiesAndFields(TypeBase type, string text) !x.IsStatic); // Since we're dragging out of a connection, we're expected to only want to execute instance methods // get extensions methods for the realType.BackendType - methods = methods.Concat(GetExtensionMethods(startConnection.Type, project.TypeFactory)).Where(x => string.IsNullOrWhiteSpace(text) || x.Name.Contains(text, StringComparison.OrdinalIgnoreCase)); + methods = methods.Concat(GetExtensionMethods(startConnection.Type, project.TypeFactory, text)); results = results.Concat(methods.Select(x => new MethodCallNode(typeof(MethodCall), x))); @@ -113,26 +161,249 @@ IEnumerable GetPropertiesAndFields(TypeBase type, string text) return results; } - private static readonly Dictionary> ExtensionMethodsMethodsPerType = []; - private static IEnumerable GetExtensionMethods(TypeBase t, TypeFactory typeFactory) + private static readonly ConcurrentDictionary>> ExtensionMethodsPerAssembly = []; + private static readonly ConcurrentDictionary<(Assembly Assembly, Type ReceiverType), ReceiverExtensionMethodCache> ExtensionMethodsPerReceiver = []; + private static readonly ConditionalWeakTable TypeShapes = new(); + private static readonly object ExtensionCatalogWarmupLock = new(); + private static Task? ExtensionCatalogWarmupTask; + + private sealed record ExtensionMethodBinding(MethodInfo? Method); + + private sealed class ReceiverExtensionMethodCache(Type receiverType) { - var query = AppDomain.CurrentDomain.GetAssemblies() - .Where(x => !x.IsDynamic) // dirty patch to prevent loading types from the generated assemblies - .SelectMany(assembly => + private readonly ConcurrentDictionary Bindings = []; + + public MethodInfo? GetOrBind(MethodInfo method) + { + return Bindings.GetOrAdd(method, definition => new(TryCloseExtensionMethod(definition, receiverType))).Method; + } + } + + private sealed class TypeShape + { + private readonly Dictionary ImplementationsByGenericDefinition; + + public TypeShape(Type type) + { + ImplementationsByGenericDefinition = GetTypeAndAncestors(type) + .Where(candidate => candidate.IsGenericType) + .GroupBy(candidate => candidate.GetGenericTypeDefinition()) + .ToDictionary(group => group.Key, group => group.ToArray()); + } + + public IEnumerable GetImplementations(Type genericDefinition) + { + return ImplementationsByGenericDefinition.TryGetValue(genericDefinition, out var implementations) + ? implementations + : []; + } + } + + public static Task WarmExtensionMethodCatalogAsync() + { + lock (ExtensionCatalogWarmupLock) + { + return ExtensionCatalogWarmupTask ??= Task.Run(() => { - if (ExtensionMethodsMethodsPerType.TryGetValue(assembly, out var methods)) - return methods; - - return ExtensionMethodsMethodsPerType[assembly] = assembly - .GetTypes() - .Where(type => !type.IsGenericType) - .SelectMany(x => x.GetMethods(BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic)) - .Where(method => method.IsDefined(typeof(ExtensionAttribute), false) && t.IsAssignableTo(typeFactory.Get(method.GetParameters()[0].ParameterType, null), out _, out _)) - .Select(x => new RealMethodInfo(typeFactory, x, typeFactory.Get(x.DeclaringType!, null))) - .ToList(); + foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies().Where(assembly => !assembly.IsDynamic)) + _ = GetExtensionMethodsFromAssembly(assembly).Count(); }); + } + } + + private static IEnumerable GetExtensionMethods(TypeBase t, TypeFactory typeFactory, string text) + { + if (t.HasUndefinedGenerics) + return []; + + Type receiverType; + try + { + receiverType = t.MakeRealType(); + } + catch + { + return []; + } + + var hasSearchText = !string.IsNullOrWhiteSpace(text); + return AppDomain.CurrentDomain.GetAssemblies() + .Where(assembly => !assembly.IsDynamic) + .SelectMany(assembly => + { + var receiverCache = ExtensionMethodsPerReceiver.GetOrAdd((assembly, receiverType), _ => new(receiverType)); + return GetExtensionMethodsFromAssembly(assembly) + .Where(method => !hasSearchText || method.Name.Contains(text, StringComparison.OrdinalIgnoreCase)) + .Select(receiverCache.GetOrBind); + }) + .Where(method => method != null) + .Select(method => (IMethodInfo)new RealMethodInfo(typeFactory, method!, typeFactory.Get(method!.DeclaringType!, null))); + } + + private static IEnumerable GetExtensionMethodsFromAssembly(Assembly assembly) + { + return ExtensionMethodsPerAssembly + .GetOrAdd(assembly, currentAssembly => new( + () => FindExtensionMethodsFromAssembly(currentAssembly), + LazyThreadSafetyMode.ExecutionAndPublication)) + .Value; + } + + private static List FindExtensionMethodsFromAssembly(Assembly assembly) + { + try + { + if (!assembly.IsDefined(typeof(ExtensionAttribute), false)) + return []; + } + catch + { + // If the assembly's custom attributes cannot be inspected, scan its types + // and let the per-method checks below decide what is usable. + } + + IEnumerable types; + try + { + types = assembly.GetTypes(); + } + catch (ReflectionTypeLoadException exception) + { + types = exception.Types.OfType(); + } + catch + { + types = []; + } + + var methods = new List(); + foreach (var type in types.Where(type => type.IsAbstract && type.IsSealed && !type.IsGenericType)) + { + IEnumerable typeMethods; + try + { + typeMethods = type.GetMethods(BindingFlags.Static | BindingFlags.Public); + } + catch + { + continue; + } + + foreach (var method in typeMethods) + { + try + { + if (method.IsDefined(typeof(ExtensionAttribute), false)) + methods.Add(method); + } + catch + { + // Some tooling assemblies contain attributes whose dependency versions + // cannot be loaded in the application. They are not usable here anyway. + } + } + } + + return methods; + } + + private static MethodInfo? TryCloseExtensionMethod(MethodInfo method, Type receiverType) + { + ParameterInfo[] parameters; + try + { + parameters = method.GetParameters(); + } + catch + { + return null; + } + if (parameters.Length == 0) + return null; + + if (!method.IsGenericMethodDefinition) + return IsExtensionReceiverCompatible(parameters[0].ParameterType, receiverType) ? method : null; + + var inferredTypes = new Dictionary(); + if (!TryInferGenericArguments(parameters[0].ParameterType, receiverType, inferredTypes)) + return null; + + var genericParameters = method.GetGenericArguments(); + if (genericParameters.Any(parameter => !inferredTypes.ContainsKey(parameter))) + return null; + + try + { + var closedMethod = method.MakeGenericMethod(genericParameters.Select(parameter => inferredTypes[parameter]).ToArray()); + return IsExtensionReceiverCompatible(closedMethod.GetParameters()[0].ParameterType, receiverType) ? closedMethod : null; + } + catch (ArgumentException) + { + return null; + } + } + + private static bool TryInferGenericArguments(Type pattern, Type actualType, Dictionary inferredTypes) + { + if (pattern.IsByRef) + pattern = pattern.GetElementType()!; + + if (pattern.IsGenericMethodParameter) + { + if (inferredTypes.TryGetValue(pattern, out var inferredType)) + return inferredType == actualType; + + inferredTypes[pattern] = actualType; + return true; + } + + if (pattern.IsArray) + { + return actualType.IsArray && + pattern.GetArrayRank() == actualType.GetArrayRank() && + TryInferGenericArguments(pattern.GetElementType()!, actualType.GetElementType()!, inferredTypes); + } + + if (!pattern.IsGenericType) + return pattern.IsAssignableFrom(actualType); + + var patternDefinition = pattern.GetGenericTypeDefinition(); + var matchingTypes = TypeShapes.GetValue(actualType, type => new(type)).GetImplementations(patternDefinition); + + foreach (var matchingType in matchingTypes) + { + var candidateInferences = new Dictionary(inferredTypes); + var patternArguments = pattern.GetGenericArguments(); + var actualArguments = matchingType.GetGenericArguments(); + if (!patternArguments.Zip(actualArguments).All(pair => TryInferGenericArguments(pair.First, pair.Second, candidateInferences))) + continue; + + inferredTypes.Clear(); + foreach (var inference in candidateInferences) + inferredTypes[inference.Key] = inference.Value; + return true; + } + + return false; + } + + private static IEnumerable GetTypeAndAncestors(Type type) + { + yield return type; + + foreach (var @interface in type.GetInterfaces()) + yield return @interface; + + for (var baseType = type.BaseType; baseType != null; baseType = baseType.BaseType) + yield return baseType; + } + + private static bool IsExtensionReceiverCompatible(Type parameterType, Type receiverType) + { + if (parameterType.IsByRef) + parameterType = parameterType.GetElementType()!; - return query; + return parameterType.IsAssignableFrom(receiverType); } } } diff --git a/src/NodeDev.Core/Nodes/Delegates/BclDelegateType.cs b/src/NodeDev.Core/Nodes/Delegates/BclDelegateType.cs new file mode 100644 index 00000000..aa867c64 --- /dev/null +++ b/src/NodeDev.Core/Nodes/Delegates/BclDelegateType.cs @@ -0,0 +1,70 @@ +using NodeDev.Core.Types; + +namespace NodeDev.Core.Nodes.Delegates; + +public static class BclDelegateType +{ + public const int MaximumParameterCount = 16; + + public static TypeBase Create(TypeFactory typeFactory, DelegateKind kind, IReadOnlyList parameterTypes, TypeBase? resultType) + { + ArgumentNullException.ThrowIfNull(typeFactory); + ArgumentNullException.ThrowIfNull(parameterTypes); + + if (parameterTypes.Count > MaximumParameterCount) + throw new ArgumentOutOfRangeException(nameof(parameterTypes), $"BCL delegates support at most {MaximumParameterCount} invocation parameters."); + + if (kind == DelegateKind.Action) + { + if (resultType != null) + throw new ArgumentException("Action delegates cannot have a result type.", nameof(resultType)); + + if (parameterTypes.Count == 0) + return typeFactory.Get(); + + var actionType = typeof(Action).Assembly.GetType($"System.Action`{parameterTypes.Count}") + ?? throw new InvalidOperationException($"Unable to resolve System.Action with arity {parameterTypes.Count}."); + return typeFactory.Get(actionType, parameterTypes.ToArray()); + } + + ArgumentNullException.ThrowIfNull(resultType); + var genericArguments = parameterTypes.Append(resultType).ToArray(); + var funcType = typeof(Func<>).Assembly.GetType($"System.Func`{genericArguments.Length}") + ?? throw new InvalidOperationException($"Unable to resolve System.Func with arity {genericArguments.Length}."); + return typeFactory.Get(funcType, genericArguments); + } + + public static bool TryDescribe(TypeBase type, out DelegateKind kind, out IReadOnlyList parameterTypes, out TypeBase? resultType) + { + kind = default; + parameterTypes = Array.Empty(); + resultType = null; + + if (type is not RealType realType) + return false; + + var backendName = realType.BackendType.FullName; + if (realType.BackendType == typeof(Action)) + { + kind = DelegateKind.Action; + return true; + } + + if (backendName?.StartsWith("System.Action`", StringComparison.Ordinal) == true && realType.Generics.Length is >= 1 and <= MaximumParameterCount) + { + kind = DelegateKind.Action; + parameterTypes = realType.Generics; + return true; + } + + if (backendName?.StartsWith("System.Func`", StringComparison.Ordinal) == true && realType.Generics.Length is >= 1 and <= MaximumParameterCount + 1) + { + kind = DelegateKind.Func; + parameterTypes = realType.Generics[..^1]; + resultType = realType.Generics[^1]; + return true; + } + + return false; + } +} diff --git a/src/NodeDev.Core/Nodes/Delegates/CreateActionNode.cs b/src/NodeDev.Core/Nodes/Delegates/CreateActionNode.cs new file mode 100644 index 00000000..20229184 --- /dev/null +++ b/src/NodeDev.Core/Nodes/Delegates/CreateActionNode.cs @@ -0,0 +1,12 @@ +namespace NodeDev.Core.Nodes.Delegates; + +public sealed class CreateActionNode : CreateDelegateNode +{ + public CreateActionNode(Graph graph, string? id = null) : base(graph, id) + { + Name = "Create Action"; + InitializeSignature(resultType: null); + } + + public override DelegateKind Kind => DelegateKind.Action; +} diff --git a/src/NodeDev.Core/Nodes/Delegates/CreateDelegateNode.cs b/src/NodeDev.Core/Nodes/Delegates/CreateDelegateNode.cs new file mode 100644 index 00000000..bb9a3ddd --- /dev/null +++ b/src/NodeDev.Core/Nodes/Delegates/CreateDelegateNode.cs @@ -0,0 +1,263 @@ +using Microsoft.CodeAnalysis.CSharp.Syntax; +using NodeDev.Core.CodeGeneration; +using NodeDev.Core.Connections; +using NodeDev.Core.Types; +using System.Text.Json; + +namespace NodeDev.Core.Nodes.Delegates; + +public abstract class CreateDelegateNode : NoFlowNode +{ + private readonly List _parameters = []; + private readonly List _captures = []; + + protected CreateDelegateNode(Graph graph, string? id = null) : base(graph, id) + { + } + + public abstract DelegateKind Kind { get; } + public IReadOnlyList Parameters => _parameters; + public TypeBase? ResultType { get; private set; } + public IReadOnlyList Captures => _captures; + public string BodyScopeId => Id; + public TypeBase DelegateType => BclDelegateType.Create(TypeFactory, Kind, _parameters.Select(x => x.Type).ToArray(), ResultType); + public IReadOnlyList CaptureInputs => Inputs; + public Connection DelegateOutput => Outputs.Single(); + public string SignatureDisplayName => DelegateType.FriendlyName; + + protected void InitializeSignature(TypeBase? resultType) + { + ResultType = resultType; + ReconcileOwnPorts(); + } + + public LambdaParameterDefinition AddParameter(string? name = null, TypeBase? type = null) + { + if (_parameters.Count >= BclDelegateType.MaximumParameterCount) + throw new InvalidOperationException($"A delegate cannot have more than {BclDelegateType.MaximumParameterCount} parameters."); + + var definition = new LambdaParameterDefinition(GetUniqueDefinitionName(name, "value", _parameters.Select(x => x.Name)), type ?? TypeFactory.Get()); + _parameters.Add(definition); + RefreshSignatureProjection(); + return definition; + } + + public void RemoveParameter(string parameterId) + { + var index = _parameters.FindIndex(x => x.Id == parameterId); + if (index < 0) + return; + + foreach (var entry in Graph.GetNodesInScope(BodyScopeId).OfType()) + RemoveConnectionAt(entry.Outputs, index + 1); + _parameters.RemoveAt(index); + RefreshSignatureProjection(); + } + + public void UpdateParameter(string parameterId, string name, TypeBase type) + { + var parameter = _parameters.FirstOrDefault(x => x.Id == parameterId) + ?? throw new ArgumentException("Unknown lambda parameter.", nameof(parameterId)); + EnsureDefinitionNameAvailable(name, parameter.Id, _parameters.Select(x => (x.Id, x.Name))); + parameter.Name = name; + parameter.Type = type; + RefreshSignatureProjection(); + } + + public LambdaCaptureDefinition AddCapture(string? name = null, TypeBase? type = null) + { + var definition = new LambdaCaptureDefinition(GetUniqueDefinitionName(name, "capture", _captures.Select(x => x.Name)), type ?? TypeFactory.Get()); + _captures.Add(definition); + RefreshSignatureProjection(); + return definition; + } + + public void RemoveCapture(string captureId) + { + var index = _captures.FindIndex(x => x.Id == captureId); + if (index < 0) + return; + + RemoveConnectionAt(Inputs, index); + foreach (var entry in Graph.GetNodesInScope(BodyScopeId).OfType()) + RemoveConnectionAt(entry.Outputs, 1 + _parameters.Count + index); + _captures.RemoveAt(index); + RefreshSignatureProjection(); + } + + public void UpdateCapture(string captureId, string name, TypeBase type) + { + var capture = _captures.FirstOrDefault(x => x.Id == captureId) + ?? throw new ArgumentException("Unknown lambda capture.", nameof(captureId)); + EnsureDefinitionNameAvailable(name, capture.Id, _captures.Select(x => (x.Id, x.Name))); + capture.Name = name; + capture.Type = type; + RefreshSignatureProjection(); + } + + public void SetResultType(TypeBase resultType) + { + if (Kind != DelegateKind.Func) + throw new InvalidOperationException("Only Func delegates have a result type."); + ResultType = resultType; + RefreshSignatureProjection(); + } + + internal void InitializeFromDelegateType(TypeBase delegateType) + { + if (!BclDelegateType.TryDescribe(delegateType, out var kind, out var parameterTypes, out var resultType) || kind != Kind) + throw new ArgumentException($"{delegateType.FriendlyName} is not a supported {Kind} delegate type.", nameof(delegateType)); + + _parameters.Clear(); + for (var index = 0; index < parameterTypes.Count; index++) + _parameters.Add(new LambdaParameterDefinition(GetUniqueDefinitionName(null, $"value{index + 1}", _parameters.Select(x => x.Name)), parameterTypes[index])); + ResultType = resultType; + RefreshSignatureProjection(); + } + + public override void OnBeforeGenericTypeDefined(IReadOnlyDictionary changedGenerics) + { + foreach (var parameter in _parameters) + parameter.Type = parameter.Type.ReplaceUndefinedGeneric(changedGenerics); + foreach (var capture in _captures) + capture.Type = capture.Type.ReplaceUndefinedGeneric(changedGenerics); + if (ResultType != null) + ResultType = ResultType.ReplaceUndefinedGeneric(changedGenerics); + } + + public override List GenericConnectionTypeDefined(Connection connection) + { + RefreshSignatureProjection(); + return InputsAndOutputs + .Concat(Graph.GetNodesInScope(BodyScopeId).SelectMany(x => x.InputsAndOutputs)) + .Distinct() + .ToList(); + } + + internal void RefreshSignatureProjection() + { + ReconcileOwnPorts(); + + foreach (var entry in Graph.GetNodesInScope(BodyScopeId).OfType()) + entry.RefreshFromOwner(this); + foreach (var returnNode in Graph.GetNodesInScope(BodyScopeId).OfType()) + returnNode.RefreshFromOwner(this); + + Graph.RaiseGraphChanged(true); + } + + private void ReconcileOwnPorts() + { + ReconcileConnections(this, Inputs, _captures.Select(x => (x.Name, x.Type))); + ReconcileConnections(this, Outputs, [("Delegate", DelegateType)]); + } + + internal static void ReconcileConnections(Node parent, List connections, IEnumerable<(string Name, TypeBase Type)> desiredConnections) + { + var desired = desiredConnections.ToList(); + + while (connections.Count > desired.Count) + { + var removed = connections[^1]; + foreach (var other in removed.Connections.ToList()) + removed.Parent.Graph.Manager.DisconnectConnectionBetween(removed, other); + connections.RemoveAt(connections.Count - 1); + } + + for (var index = 0; index < desired.Count; index++) + { + var item = desired[index]; + if (index >= connections.Count) + { + connections.Add(new Connection(item.Name, parent, item.Type)); + continue; + } + + connections[index].Name = item.Name; + if (connections[index].Type != item.Type) + { + connections[index].UpdateTypeAndTextboxVisibility(item.Type, overrideInitialType: true); + DisconnectIncompatibleLinks(connections[index]); + } + } + } + + private static void RemoveConnectionAt(List connections, int index) + { + if (index < 0 || index >= connections.Count) + return; + var removed = connections[index]; + foreach (var other in removed.Connections.ToList()) + removed.Parent.Graph.Manager.DisconnectConnectionBetween(removed, other); + connections.RemoveAt(index); + } + + private static void DisconnectIncompatibleLinks(Connection connection) + { + foreach (var other in connection.Connections.ToList()) + { + var source = connection.IsOutput ? connection : other; + var destination = connection.IsInput ? connection : other; + if (!source.IsAssignableTo(destination, true, true, out _, out _, out _)) + connection.Parent.Graph.Manager.DisconnectConnectionBetween(connection, other); + } + } + + private static string GetUniqueDefinitionName(string? requestedName, string fallback, IEnumerable existingNames) + { + var existing = existingNames.ToHashSet(StringComparer.Ordinal); + var baseName = string.IsNullOrWhiteSpace(requestedName) ? fallback : requestedName.Trim(); + var name = baseName; + var suffix = 2; + while (existing.Contains(name)) + name = $"{baseName}_{suffix++}"; + return name; + } + + private static void EnsureDefinitionNameAvailable(string name, string currentId, IEnumerable<(string Id, string Name)> definitions) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Lambda definition names cannot be empty.", nameof(name)); + if (definitions.Any(x => x.Id != currentId && x.Name == name)) + throw new ArgumentException($"A lambda definition named '{name}' already exists.", nameof(name)); + } + + private sealed record SerializedDefinition(string Id, string Name, TypeBase.SerializedType Type); + private sealed record SerializedDelegatePayload(DelegateKind Kind, List Parameters, TypeBase.SerializedType? ResultType, List Captures); + + protected override string? SerializePayload() + { + return JsonSerializer.Serialize(new SerializedDelegatePayload( + Kind, + _parameters.Select(x => new SerializedDefinition(x.Id, x.Name, x.Type.SerializeWithFullTypeName())).ToList(), + ResultType?.SerializeWithFullTypeName(), + _captures.Select(x => new SerializedDefinition(x.Id, x.Name, x.Type.SerializeWithFullTypeName())).ToList())); + } + + protected override void DeserializePayload(string? payload) + { + if (payload == null) + return; + + var serialized = JsonSerializer.Deserialize(payload) + ?? throw new InvalidOperationException("Unable to deserialize delegate signature payload."); + if (serialized.Kind != Kind) + throw new InvalidOperationException($"Serialized delegate kind {serialized.Kind} does not match node type {Kind}."); + + _parameters.Clear(); + _parameters.AddRange(serialized.Parameters.Select(x => new LambdaParameterDefinition(x.Name, TypeBase.Deserialize(TypeFactory, x.Type), x.Id))); + _captures.Clear(); + _captures.AddRange(serialized.Captures.Select(x => new LambdaCaptureDefinition(x.Name, TypeBase.Deserialize(TypeFactory, x.Type), x.Id))); + ResultType = serialized.ResultType == null ? null : TypeBase.Deserialize(TypeFactory, serialized.ResultType); + } + + internal override void FinalizeDeserialization() + { + ReconcileOwnPorts(); + } + + internal override ExpressionSyntax GenerateRoslynExpression(GenerationContext context) + { + return new RoslynGraphBuilder(Graph, context).BuildLambdaExpression(this); + } +} diff --git a/src/NodeDev.Core/Nodes/Delegates/CreateFuncNode.cs b/src/NodeDev.Core/Nodes/Delegates/CreateFuncNode.cs new file mode 100644 index 00000000..78a04397 --- /dev/null +++ b/src/NodeDev.Core/Nodes/Delegates/CreateFuncNode.cs @@ -0,0 +1,12 @@ +namespace NodeDev.Core.Nodes.Delegates; + +public sealed class CreateFuncNode : CreateDelegateNode +{ + public CreateFuncNode(Graph graph, string? id = null) : base(graph, id) + { + Name = "Create Func"; + InitializeSignature(TypeFactory.Get()); + } + + public override DelegateKind Kind => DelegateKind.Func; +} diff --git a/src/NodeDev.Core/Nodes/Delegates/DelegateKind.cs b/src/NodeDev.Core/Nodes/Delegates/DelegateKind.cs new file mode 100644 index 00000000..9fe3c025 --- /dev/null +++ b/src/NodeDev.Core/Nodes/Delegates/DelegateKind.cs @@ -0,0 +1,7 @@ +namespace NodeDev.Core.Nodes.Delegates; + +public enum DelegateKind +{ + Action, + Func +} diff --git a/src/NodeDev.Core/Nodes/Delegates/InvokeDelegateNode.cs b/src/NodeDev.Core/Nodes/Delegates/InvokeDelegateNode.cs new file mode 100644 index 00000000..5d6c6ada --- /dev/null +++ b/src/NodeDev.Core/Nodes/Delegates/InvokeDelegateNode.cs @@ -0,0 +1,83 @@ +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using NodeDev.Core.CodeGeneration; +using NodeDev.Core.Connections; +using NodeDev.Core.Types; +using SF = Microsoft.CodeAnalysis.CSharp.SyntaxFactory; + +namespace NodeDev.Core.Nodes.Delegates; + +public sealed class InvokeDelegateNode : NormalFlowNode +{ + public InvokeDelegateNode(Graph graph, string? id = null) : base(graph, id) + { + Name = "Invoke Delegate"; + Inputs.Add(new Connection("Delegate", this, new UndefinedGenericType($"Delegate_{Id.Replace('-', '_')}"))); + } + + public Connection DelegateInput => Inputs[1]; + public IReadOnlyList InvocationInputs => Inputs.Skip(2).ToList(); + public Connection? ResultOutput => Outputs.Skip(1).SingleOrDefault(); + + internal void InitializeFromDelegateType(TypeBase delegateType) + { + if (!BclDelegateType.TryDescribe(delegateType, out _, out var parameterTypes, out var resultType)) + throw new ArgumentException($"{delegateType.FriendlyName} is not a supported Action or Func delegate.", nameof(delegateType)); + + var desiredInputs = new List<(string Name, TypeBase Type)> + { + ("Exec", TypeFactory.ExecType), + ("Delegate", delegateType) + }; + for (var index = 0; index < parameterTypes.Count; index++) + desiredInputs.Add(($"Argument {index + 1}", parameterTypes[index])); + CreateDelegateNode.ReconcileConnections(this, Inputs, desiredInputs); + + var desiredOutputs = new List<(string Name, TypeBase Type)> { ("Exec", TypeFactory.ExecType) }; + if (resultType != null) + desiredOutputs.Add(("Result", resultType)); + CreateDelegateNode.ReconcileConnections(this, Outputs, desiredOutputs); + Graph.RaiseGraphChanged(true); + } + + public override List GenericConnectionTypeDefined(Connection connection) + { + if (connection == DelegateInput && BclDelegateType.TryDescribe(connection.Type, out _, out _, out _)) + { + InitializeFromDelegateType(connection.Type); + return InputsAndOutputs.ToList(); + } + return []; + } + + internal override void FinalizeDeserialization() + { + if (Inputs.Count > 1 && BclDelegateType.TryDescribe(Inputs[1].Type, out _, out _, out _)) + InitializeFromDelegateType(Inputs[1].Type); + } + + internal override StatementSyntax GenerateRoslynStatement(Dictionary? subChunks, GenerationContext context) + { + if (!BclDelegateType.TryDescribe(DelegateInput.Type, out _, out _, out var resultType)) + throw new InvalidOperationException("Delegate input must resolve to a supported Action or Func type before generation."); + + var delegateName = context.GetVariableName(DelegateInput) + ?? throw new InvalidOperationException("Delegate input was not resolved."); + var arguments = InvocationInputs.Select(input => + { + var name = context.GetVariableName(input) + ?? throw new InvalidOperationException($"Delegate argument {input.Name} was not resolved."); + return SF.Argument(SF.IdentifierName(name)); + }); + var invocation = SF.InvocationExpression(SF.IdentifierName(delegateName)) + .WithArgumentList(SF.ArgumentList(SF.SeparatedList(arguments))); + + if (resultType == null) + return SF.ExpressionStatement(invocation); + + var resultName = ResultOutput == null ? null : context.GetVariableName(ResultOutput); + if (resultName == null) + throw new InvalidOperationException("Delegate result output was not declared."); + return SF.ExpressionStatement(SF.AssignmentExpression(SyntaxKind.SimpleAssignmentExpression, SF.IdentifierName(resultName), invocation)); + } +} diff --git a/src/NodeDev.Core/Nodes/Delegates/LambdaCaptureDefinition.cs b/src/NodeDev.Core/Nodes/Delegates/LambdaCaptureDefinition.cs new file mode 100644 index 00000000..442ccc3a --- /dev/null +++ b/src/NodeDev.Core/Nodes/Delegates/LambdaCaptureDefinition.cs @@ -0,0 +1,17 @@ +using NodeDev.Core.Types; + +namespace NodeDev.Core.Nodes.Delegates; + +public sealed class LambdaCaptureDefinition +{ + public LambdaCaptureDefinition(string name, TypeBase type, string? id = null) + { + Id = id ?? Guid.NewGuid().ToString(); + Name = name; + Type = type; + } + + public string Id { get; } + public string Name { get; internal set; } + public TypeBase Type { get; internal set; } +} diff --git a/src/NodeDev.Core/Nodes/Delegates/LambdaCompleteNode.cs b/src/NodeDev.Core/Nodes/Delegates/LambdaCompleteNode.cs new file mode 100644 index 00000000..61da9966 --- /dev/null +++ b/src/NodeDev.Core/Nodes/Delegates/LambdaCompleteNode.cs @@ -0,0 +1,29 @@ +using Microsoft.CodeAnalysis.CSharp.Syntax; +using NodeDev.Core.CodeGeneration; +using NodeDev.Core.Connections; +using SF = Microsoft.CodeAnalysis.CSharp.SyntaxFactory; + +namespace NodeDev.Core.Nodes.Delegates; + +public sealed class LambdaCompleteNode : Flow.FlowNode +{ + public LambdaCompleteNode(Graph graph, string? id = null) : base(graph, id) + { + Name = "Lambda Complete"; + Inputs.Add(new Connection("Exec", this, TypeFactory.ExecType)); + } + + public override string TitleColor => "red"; + public override bool IsFlowNode => true; + public override bool BreaksDeadEnd => true; + public Connection ExecInput => Inputs[0]; + + internal override StatementSyntax GenerateRoslynStatement(Dictionary? subChunks, GenerationContext context) + { + return SF.ReturnStatement(); + } + + public override bool DoesOutputPathAllowDeadEnd(Connection execOutput) => true; + public override bool DoesOutputPathAllowMerge(Connection execOutput) => throw new NotSupportedException(); + public override string GetExecOutputPathId(string pathId, Connection execOutput) => throw new NotSupportedException(); +} diff --git a/src/NodeDev.Core/Nodes/Delegates/LambdaEntryNode.cs b/src/NodeDev.Core/Nodes/Delegates/LambdaEntryNode.cs new file mode 100644 index 00000000..8b90d0bd --- /dev/null +++ b/src/NodeDev.Core/Nodes/Delegates/LambdaEntryNode.cs @@ -0,0 +1,60 @@ +using NodeDev.Core.Connections; + +namespace NodeDev.Core.Nodes.Delegates; + +public sealed class LambdaEntryNode : Flow.FlowNode +{ + public LambdaEntryNode(Graph graph, string? id = null) : base(graph, id) + { + Name = "Lambda Entry"; + Outputs.Add(new Connection("Exec", this, TypeFactory.ExecType)); + } + + public override string TitleColor => "red"; + public override bool IsFlowNode => true; + public Connection ExecOutput => Outputs[0]; + public IReadOnlyList ParameterOutputs + { + get + { + var owner = GetOwner(); + return Outputs.Skip(1).Take(owner.Parameters.Count).ToList(); + } + } + public IReadOnlyList CaptureOutputs + { + get + { + var owner = GetOwner(); + return Outputs.Skip(1 + owner.Parameters.Count).Take(owner.Captures.Count).ToList(); + } + } + + internal void RefreshFromOwner(CreateDelegateNode owner) + { + if (CallableScopeId != owner.BodyScopeId) + throw new InvalidOperationException("Lambda entry does not belong to the supplied delegate scope."); + + var desired = new List<(string Name, Types.TypeBase Type)> { ("Exec", TypeFactory.ExecType) }; + desired.AddRange(owner.Parameters.Select(x => (x.Name, x.Type))); + desired.AddRange(owner.Captures.Select(x => ($"Captured {x.Name}", x.Type))); + CreateDelegateNode.ReconcileConnections(this, Outputs, desired); + } + + private CreateDelegateNode GetOwner() + { + return Graph.GetOwningLambda(CallableScopeId) + ?? throw new InvalidOperationException("Lambda entry has no owning delegate."); + } + + internal override void FinalizeDeserialization() + { + var owner = Graph.GetOwningLambda(CallableScopeId); + if (owner != null) + RefreshFromOwner(owner); + } + + public override bool DoesOutputPathAllowDeadEnd(Connection execOutput) => false; + public override bool DoesOutputPathAllowMerge(Connection execOutput) => throw new NotSupportedException(); + public override string GetExecOutputPathId(string pathId, Connection execOutput) => throw new NotSupportedException(); +} diff --git a/src/NodeDev.Core/Nodes/Delegates/LambdaParameterDefinition.cs b/src/NodeDev.Core/Nodes/Delegates/LambdaParameterDefinition.cs new file mode 100644 index 00000000..d90856ea --- /dev/null +++ b/src/NodeDev.Core/Nodes/Delegates/LambdaParameterDefinition.cs @@ -0,0 +1,17 @@ +using NodeDev.Core.Types; + +namespace NodeDev.Core.Nodes.Delegates; + +public sealed class LambdaParameterDefinition +{ + public LambdaParameterDefinition(string name, TypeBase type, string? id = null) + { + Id = id ?? Guid.NewGuid().ToString(); + Name = name; + Type = type; + } + + public string Id { get; } + public string Name { get; internal set; } + public TypeBase Type { get; internal set; } +} diff --git a/src/NodeDev.Core/Nodes/Delegates/LambdaReturnNode.cs b/src/NodeDev.Core/Nodes/Delegates/LambdaReturnNode.cs new file mode 100644 index 00000000..bdff2433 --- /dev/null +++ b/src/NodeDev.Core/Nodes/Delegates/LambdaReturnNode.cs @@ -0,0 +1,67 @@ +using Microsoft.CodeAnalysis.CSharp.Syntax; +using NodeDev.Core.CodeGeneration; +using NodeDev.Core.Connections; +using System.Text.Json; +using SF = Microsoft.CodeAnalysis.CSharp.SyntaxFactory; + +namespace NodeDev.Core.Nodes.Delegates; + +public sealed class LambdaReturnNode : Flow.FlowNode +{ + private sealed record SerializedLambdaReturnPayload(bool IsImplicit); + + public LambdaReturnNode(Graph graph, string? id = null) : base(graph, id) + { + Name = "Lambda Return"; + Inputs.Add(new Connection("Exec", this, TypeFactory.ExecType)); + Inputs.Add(new Connection("Result", this, new Types.UndefinedGenericType($"LambdaReturn_{Id.Replace('-', '_')}"))); + } + + public override string TitleColor => "red"; + public override bool IsFlowNode => true; + public override bool BreaksDeadEnd => true; + /// + /// Identifies the terminal created with the Func body. The canvas projects this + /// terminal onto the lambda boundary; user-added returns remain ordinary nodes. + /// + public bool IsImplicit { get; internal set; } + public Connection ExecInput => Inputs[0]; + public Connection ResultInput => Inputs[1]; + + internal void RefreshFromOwner(CreateDelegateNode owner) + { + if (owner.Kind != DelegateKind.Func || owner.ResultType == null) + throw new InvalidOperationException("Lambda return nodes are valid only in Func scopes."); + if (CallableScopeId != owner.BodyScopeId) + throw new InvalidOperationException("Lambda return does not belong to the supplied delegate scope."); + CreateDelegateNode.ReconcileConnections(this, Inputs, [("Exec", TypeFactory.ExecType), ("Result", owner.ResultType)]); + } + + internal override void FinalizeDeserialization() + { + var owner = Graph.GetOwningLambda(CallableScopeId); + if (owner?.Kind == DelegateKind.Func) + RefreshFromOwner(owner); + } + + protected override string? SerializePayload() => + JsonSerializer.Serialize(new SerializedLambdaReturnPayload(IsImplicit)); + + protected override void DeserializePayload(string? payload) + { + IsImplicit = JsonSerializer.Deserialize( + payload ?? throw new InvalidOperationException("Lambda return payload is missing."))?.IsImplicit + ?? throw new InvalidOperationException("Unable to deserialize lambda return payload."); + } + + internal override StatementSyntax GenerateRoslynStatement(Dictionary? subChunks, GenerationContext context) + { + var resultName = context.GetVariableName(ResultInput) + ?? throw new InvalidOperationException("Lambda result input was not resolved."); + return SF.ReturnStatement(SF.IdentifierName(resultName)); + } + + public override bool DoesOutputPathAllowDeadEnd(Connection execOutput) => true; + public override bool DoesOutputPathAllowMerge(Connection execOutput) => throw new NotSupportedException(); + public override string GetExecOutputPathId(string pathId, Connection execOutput) => throw new NotSupportedException(); +} diff --git a/src/NodeDev.Core/Nodes/MethodCall.cs b/src/NodeDev.Core/Nodes/MethodCall.cs index 517b92f4..0d604975 100644 --- a/src/NodeDev.Core/Nodes/MethodCall.cs +++ b/src/NodeDev.Core/Nodes/MethodCall.cs @@ -17,7 +17,7 @@ public class MethodCall : NormalFlowNode public class TargetMethodDecoration : INodeDecoration { private record class SavedMethodInfoParameter(string Type); - private record class SavedMethodInfo(string Type, string Name, SavedMethodInfoParameter[] ParamTypes); + private record class SavedMethodInfo(string Type, string Name, SavedMethodInfoParameter[] ParamTypes, string[]? GenericArguments = null); internal IMethodInfo TargetMethod { get; set; } public TargetMethodDecoration(IMethodInfo targetMethod) @@ -27,7 +27,16 @@ public TargetMethodDecoration(IMethodInfo targetMethod) public string Serialize() { - return JsonSerializer.Serialize(new SavedMethodInfo(TargetMethod.DeclaringType.SerializeWithFullTypeNameString(), TargetMethod.Name, TargetMethod.GetParameters().Select(p => new SavedMethodInfoParameter(p.ParameterType.SerializeWithFullTypeNameString())).ToArray())); + var genericArguments = (TargetMethod as RealMethodInfo)? + .GetClosedGenericArguments()? + .Select(type => type.SerializeWithFullTypeNameString()) + .ToArray(); + + return JsonSerializer.Serialize(new SavedMethodInfo( + TargetMethod.DeclaringType.SerializeWithFullTypeNameString(), + TargetMethod.Name, + TargetMethod.GetParameters().Select(p => new SavedMethodInfoParameter(p.ParameterType.SerializeWithFullTypeNameString())).ToArray(), + genericArguments)); } public static INodeDecoration Deserialize(TypeFactory typeFactory, string Json) @@ -35,8 +44,21 @@ public static INodeDecoration Deserialize(TypeFactory typeFactory, string Json) var info = JsonSerializer.Deserialize(Json) ?? throw new Exception("Unable to deserialize method info"); var type = TypeBase.DeserializeFullTypeNameString(typeFactory, info.Type); - var parameterTypes = info.ParamTypes.Select(x => TypeBase.DeserializeFullTypeNameString(typeFactory, x.Type)); - var method = type.GetMethods(info.Name).FirstOrDefault(x => parameterTypes.SequenceEqual(x.GetParameters().Select(y => y.ParameterType))); + var parameterTypes = info.ParamTypes.Select(x => TypeBase.DeserializeFullTypeNameString(typeFactory, x.Type)).ToArray(); + IEnumerable methods = type.GetMethods(info.Name); + if (info.GenericArguments != null) + { + var genericArguments = info.GenericArguments + .Select(argument => TypeBase.DeserializeFullTypeNameString(typeFactory, argument)) + .ToArray(); + + methods = methods + .OfType() + .Select(method => method.CloseGenericMethod(genericArguments)) + .OfType(); + } + + var method = methods.FirstOrDefault(x => parameterTypes.SequenceEqual(x.GetParameters().Select(y => y.ParameterType))); if (method == null) throw new Exception("Unable to find method:" + info.Name); @@ -60,14 +82,26 @@ public override IEnumerable AlternatesOverloads { get { - var parentType = TargetMethod?.DeclaringType; - if (TargetMethod == null || parentType == null) - return []; + return GetTargetMethodOverloads().Select(x => x.AlternateOverload()).ToList(); + } + } - var methods = parentType.GetMethods(TargetMethod.Name); + private IEnumerable GetTargetMethodOverloads() + { + if (TargetMethod == null) + return []; - return methods.Select(x => x.AlternateOverload()).ToList(); + IEnumerable methods = TargetMethod.DeclaringType.GetMethods(TargetMethod.Name); + var genericArguments = (TargetMethod as RealMethodInfo)?.GetClosedGenericArguments(); + if (genericArguments != null) + { + methods = methods + .OfType() + .Select(method => method.CloseGenericMethod(genericArguments)) + .OfType(); } + + return methods; } public MethodCall(Graph graph, string? id = null) : base(graph, id) @@ -85,16 +119,14 @@ protected override void Deserialize(SerializedNode serializedNodeObj) public override void SelectOverload(AlternateOverload overload, out List newConnections, out List removedConnections) { // find the MethodInfo for the overload - var parentType = TargetMethod?.DeclaringType; - if (TargetMethod == null || parentType == null) + if (TargetMethod == null) { newConnections = []; removedConnections = []; return; } - var method = parentType - .GetMethods(TargetMethod.Name) + var method = GetTargetMethodOverloads() .FirstOrDefault(x => x.GetParameters().Select(y => y.ParameterType).SequenceEqual(overload.Parameters.Select(y => y.ParameterType)) && // check if the types match x.GetParameters().Select(y => y.IsOut).SequenceEqual(overload.Parameters.Select(y => y.IsOut))); // check if the IsOut match diff --git a/src/NodeDev.Core/Nodes/Node.cs b/src/NodeDev.Core/Nodes/Node.cs index d5cef4f4..054a2480 100644 --- a/src/NodeDev.Core/Nodes/Node.cs +++ b/src/NodeDev.Core/Nodes/Node.cs @@ -18,6 +18,12 @@ public Node(Graph graph, string? id = null) public string Id { get; } + /// + /// Identifies the callable body this node belongs to. Null is the containing + /// method body; a delegate body uses its owning CreateDelegateNode id. + /// + public string? CallableScopeId { get; internal set; } + public virtual string Name { get; set; } = ""; public virtual bool AllowEditingName { get; } = false; @@ -279,10 +285,26 @@ public void ToggleBreakpoint() #region Serialization - public record SerializedNode(string Type, string Id, string Name, List Inputs, List Outputs, Dictionary Decorations); + public record SerializedNode( + string Type, + string Id, + string Name, + List Inputs, + List Outputs, + Dictionary Decorations, + string? CallableScopeId = null, + string? Payload = null); internal SerializedNode Serialize() { - var serializedNode = new SerializedNode(GetType().FullName!, Id, Name, Inputs.Select(x => x.Serialize()).ToList(), Outputs.Select(x => x.Serialize()).ToList(), Decorations.ToDictionary(x => x.Key.FullName!, x => x.Value.Serialize())); + var serializedNode = new SerializedNode( + GetType().FullName!, + Id, + Name, + Inputs.Select(x => x.Serialize()).ToList(), + Outputs.Select(x => x.Serialize()).ToList(), + Decorations.ToDictionary(x => x.Key.FullName!, x => x.Value.Serialize()), + CallableScopeId, + SerializePayload()); return serializedNode; } @@ -319,6 +341,7 @@ protected virtual void Deserialize(SerializedNode serializedNodeObj) Outputs.Clear(); Name = serializedNodeObj.Name; + CallableScopeId = serializedNodeObj.CallableScopeId; foreach (var input in serializedNodeObj.Inputs) { var connection = Connection.Deserialize(this, input, true); @@ -330,8 +353,27 @@ protected virtual void Deserialize(SerializedNode serializedNodeObj) var connection = Connection.Deserialize(this, output, false); Outputs.Add(connection); } + + DeserializePayload(serializedNodeObj.Payload); } + /// + /// Serializes semantic node-specific state. UI-only state belongs in + /// decorations; callable signatures use this payload hook. + /// + protected virtual string? SerializePayload() => null; + + /// + /// Loads semantic node-specific state without reconciling ports. Port and + /// cross-node reconciliation is deferred until the whole graph is loaded. + /// + protected virtual void DeserializePayload(string? payload) { } + + /// + /// Called by Graph after all nodes and serialized connections exist. + /// + internal virtual void FinalizeDeserialization() { } + #endregion } } diff --git a/src/NodeDev.Core/Types/RealMethodInfo.cs b/src/NodeDev.Core/Types/RealMethodInfo.cs index e27f6f12..31538d7a 100644 --- a/src/NodeDev.Core/Types/RealMethodInfo.cs +++ b/src/NodeDev.Core/Types/RealMethodInfo.cs @@ -33,8 +33,35 @@ public RealMethodInfo(TypeFactory typeFactory, MethodInfo method, RealType decla DeclaringRealType = declaringType; } + internal TypeBase[]? GetClosedGenericArguments() + { + if (!Method.IsGenericMethod || Method.IsGenericMethodDefinition) + return null; + + return Method.GetGenericArguments().Select(type => (TypeBase)TypeFactory.Get(type, null)).ToArray(); + } + + internal RealMethodInfo? CloseGenericMethod(IReadOnlyList genericArguments) + { + if (!Method.IsGenericMethodDefinition || Method.GetGenericArguments().Length != genericArguments.Count) + return null; + + try + { + var closedMethod = Method.MakeGenericMethod(genericArguments.Select(argument => argument.MakeRealType()).ToArray()); + return new RealMethodInfo(TypeFactory, closedMethod, DeclaringRealType); + } + catch (ArgumentException) + { + return null; + } + } + public MethodInfo CreateMethodInfo() { + if (Method.IsGenericMethod && !Method.ContainsGenericParameters) + return Method; + // This seriously needs to be optimized, this will be called a lot and it's slow as hell return DeclaringRealType.MakeRealType().GetMethod(Method.Name, GetParameters().Select(x => x.ParameterType.MakeRealType()).ToArray())!; } diff --git a/src/NodeDev.EndToEndTests/Fixtures/AppServerFixture.cs b/src/NodeDev.EndToEndTests/Fixtures/AppServerFixture.cs index 15898cce..b4fb1ddd 100644 --- a/src/NodeDev.EndToEndTests/Fixtures/AppServerFixture.cs +++ b/src/NodeDev.EndToEndTests/Fixtures/AppServerFixture.cs @@ -1,4 +1,8 @@ +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; namespace NodeDev.EndToEndTests.Fixtures; @@ -25,4 +29,15 @@ public async Task DisposeAsync() internal class BlazorWebAppFactory : WebApplicationFactory { -} \ No newline at end of file + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.UseEnvironment("Development"); + builder.ConfigureLogging(logging => + { + logging.ClearProviders(); + logging.AddConsole(); + }); + builder.ConfigureServices(services => + services.AddDataProtection().UseEphemeralDataProtectionProvider()); + } +} diff --git a/src/NodeDev.EndToEndTests/Pages/HomePage.cs b/src/NodeDev.EndToEndTests/Pages/HomePage.cs index 124cd46d..a11c9e35 100644 --- a/src/NodeDev.EndToEndTests/Pages/HomePage.cs +++ b/src/NodeDev.EndToEndTests/Pages/HomePage.cs @@ -179,25 +179,27 @@ public async Task DragNodeTo(string nodeName, float targetX, float targetY) if (box == null) throw new Exception($"Could not get bounding box for node '{nodeName}'"); - await node.ClickAsync(); - // Calculate center of node as the starting point var sourceX = (float)(box.X + box.Width / 2); var sourceY = (float)(box.Y + box.Height / 2); Console.WriteLine($"Dragging {nodeName} from ({sourceX}, {sourceY}) to ({targetX}, {targetY})"); - // Perform manual drag with proper event sequence for Blazor.Diagrams - // 1. Move mouse to starting position + // Dismiss any hover UI left by the preceding action (for example, a MudBlazor + // tooltip from a node-search result) before interacting with the diagram node. + await _user.Mouse.MoveAsync(0, 0); + + // Perform manual drag with proper event sequence for Blazor.Diagrams. + // Moving to the node and pressing the mouse button selects it and starts the drag; + // a separate click is both redundant and vulnerable to transient popovers. await _user.Mouse.MoveAsync(sourceX, sourceY); - // 2. Press mouse button down (pointerdown event) await _user.Mouse.DownAsync(); await Task.Delay(50); // Single delay for event propagation - // 3. Move mouse to target position with multiple steps (pointermove events) + // Move mouse to target position with multiple steps (pointermove events) await _user.Mouse.MoveAsync(targetX, targetY, new() { Steps = 15 }); - // 4. Release mouse button (pointerup event) + // Release mouse button (pointerup event) await _user.Mouse.UpAsync(); // Wait for the UI to update after drag @@ -316,7 +318,11 @@ public async Task DeleteConnection(string sourceNodeName, string sourcePortName, public async Task TakeScreenshot(string fileName) { - await _user.ScreenshotAsync(new() { Path = fileName }); + var screenshotPath = fileName; + if (OperatingSystem.IsWindows() && fileName.StartsWith("/tmp/", StringComparison.Ordinal)) + screenshotPath = Path.Combine(Path.GetTempPath(), fileName["/tmp/".Length..]); + + await _user.ScreenshotAsync(new() { Path = screenshotPath }); } // Advanced Node Operations @@ -353,7 +359,11 @@ public async Task AddNodeFromSearch(string nodeType) } catch (TimeoutException) { - throw new NotImplementedException($"Node search result not found - [data-test-id='node-search-result'][data-node-type='{nodeType}']. Search may not be open or node type may not exist."); + var availableTypes = await _user + .Locator("[data-test-id='node-search-result']") + .EvaluateAllAsync("elements => elements.map(element => element.getAttribute('data-node-type') ?? '')"); + throw new NotImplementedException( + $"Node search result '{nodeType}' was not found. Available results: {string.Join(", ", availableTypes)}"); } } @@ -879,4 +889,4 @@ public async Task AddNodeToCanvas(string nodeType) Console.WriteLine($"Added node '{nodeType}' to canvas"); } -} \ No newline at end of file +} diff --git a/src/NodeDev.EndToEndTests/Tests/LambdaRegionTests.cs b/src/NodeDev.EndToEndTests/Tests/LambdaRegionTests.cs new file mode 100644 index 00000000..5270bf3b --- /dev/null +++ b/src/NodeDev.EndToEndTests/Tests/LambdaRegionTests.cs @@ -0,0 +1,79 @@ +using Microsoft.Playwright; +using NodeDev.EndToEndTests.Fixtures; +using Xunit; + +namespace NodeDev.EndToEndTests.Tests; + +public class LambdaRegionTests : E2ETestBase +{ + public LambdaRegionTests(AppServerFixture app, PlaywrightFixture playwright) + : base(app, playwright) + { + } + + [Fact(Timeout = 60_000)] + public async Task CreateFunc_ShowsLiveRegionBodyAndSignatureControls() + { + await HomePage.CreateNewProject(); + await HomePage.OpenProjectExplorerProjectTab(); + await HomePage.HasClass("Program"); + await HomePage.ClickClass("Program"); + await HomePage.OpenMethod("Main"); + SetupConsoleMonitoring(); + + await HomePage.SearchForNodes("CreateFuncNode"); + await HomePage.AddNodeFromSearch("CreateFuncNode"); + + var region = Page.Locator("[data-test-id='lambda-region']").Last; + await region.WaitForAsync(new() { State = WaitForSelectorState.Visible }); + var regionBox = await region.BoundingBoxAsync() ?? throw new InvalidOperationException("Lambda region has no bounds."); + Assert.True(regionBox.Width >= 590, $"Expected a usable lambda width, but got {regionBox.Width}px."); + Assert.True(regionBox.Height >= 410, $"Expected a usable lambda height, but got {regionBox.Height}px."); + await region.Locator("[data-test-id='graph-node'][data-test-node-name='Lambda Entry']").WaitForAsync(new() { State = WaitForSelectorState.Visible }); + await region.Locator("[data-test-id='lambda-boundary-return']").WaitForAsync(new() { State = WaitForSelectorState.Visible }); + await region.Locator("[data-test-id='lambda-return-exec-port']").WaitForAsync(new() { State = WaitForSelectorState.Visible }); + await region.Locator("[data-test-id='lambda-return-result-port']").WaitForAsync(new() { State = WaitForSelectorState.Visible }); + await Assertions.Expect(region.Locator("[data-test-id='graph-node'][data-test-node-name='Lambda Return']")).ToHaveCountAsync(0); + await region.Locator("[data-test-id='lambda-delegate-port']").WaitForAsync(new() { State = WaitForSelectorState.Visible }); + + await region.Locator("[data-test-id='lambda-add-parameter']").ClickAsync(); + var rebuiltRegion = Page.Locator("[data-test-id='lambda-region']").Last; + await rebuiltRegion.Locator("[data-test-id='lambda-parameter']").WaitForAsync(new() { State = WaitForSelectorState.Visible }); + + // CreateFunc now has a bool result by default, so use a compatible root-scope + // output to exercise automatic capture across the lambda boundary. + await HomePage.SearchForNodes("And"); + await HomePage.AddNodeFromSearch("And"); + var sourcePort = Page + .Locator("[data-test-id='graph-node'][data-test-node-name='And']") + .Last + .Locator(".col.output") + .Filter(new() { HasText = "c" }) + .Locator(".diagram-port") + .First; + var resultPort = rebuiltRegion + .Locator("[data-test-id='lambda-return-result-port']") + .Locator(".diagram-port") + .First; + await DragPortToPort(sourcePort, resultPort); + + rebuiltRegion = Page.Locator("[data-test-id='lambda-region']").Last; + await rebuiltRegion.Locator("[data-test-id='lambda-capture']").WaitForAsync(new() { State = WaitForSelectorState.Visible }); + await rebuiltRegion.Locator("[data-test-id='lambda-capture-port']").WaitForAsync(new() { State = WaitForSelectorState.Visible }); + + AssertNoConsoleErrors(); + } + + private async Task DragPortToPort(ILocator source, ILocator destination) + { + await source.WaitForAsync(new() { State = WaitForSelectorState.Visible }); + await destination.WaitForAsync(new() { State = WaitForSelectorState.Visible }); + var sourceBox = await source.BoundingBoxAsync() ?? throw new InvalidOperationException("Source port has no bounds."); + var destinationBox = await destination.BoundingBoxAsync() ?? throw new InvalidOperationException("Destination port has no bounds."); + + await Page.Mouse.MoveAsync((float)(sourceBox.X + sourceBox.Width / 2), (float)(sourceBox.Y + sourceBox.Height / 2)); + await Page.Mouse.DownAsync(); + await Page.Mouse.MoveAsync((float)(destinationBox.X + destinationBox.Width / 2), (float)(destinationBox.Y + destinationBox.Height / 2), new() { Steps = 20 }); + await Page.Mouse.UpAsync(); + } +} diff --git a/src/NodeDev.Tests/GraphManagerServiceTests.cs b/src/NodeDev.Tests/GraphManagerServiceTests.cs index 94fb1e8f..64bb4951 100644 --- a/src/NodeDev.Tests/GraphManagerServiceTests.cs +++ b/src/NodeDev.Tests/GraphManagerServiceTests.cs @@ -9,6 +9,72 @@ namespace NodeDev.Tests; public class GraphManagerServiceTests : NodeDevTestsBase { + [Fact] + public void SelectingNewListOverloadRefreshesPortsAndKeepsExecConnected() + { + var project = Project.CreateNewDefaultProject(out var main); + var graphCanvas = Substitute.For(); + graphCanvas.Graph.Returns(main.Graph); + main.Graph.GraphCanvas = graphCanvas; + + var newNode = new New(main.Graph); + main.Graph.Manager.AddNode(newNode); + main.Graph.Manager.AddNewConnectionBetween(main.EntryNode!.Outputs[0], newNode.Inputs[0]); + main.Graph.Manager.PropagateNewGeneric( + newNode, + new Dictionary + { + ["T"] = project.TypeFactory.Get>() + }, + useInitialTypes: false, + initiatingConnection: null, + overrideInitialTypes: true); + + var capacityOverload = Assert.Single( + newNode.AlternatesOverloads, + overload => overload.Parameters.Count == 1 && overload.Parameters[0].Name == "capacity"); + graphCanvas.ClearReceivedCalls(); + + main.Graph.Manager.SelectNodeOverload(newNode, capacityOverload); + + Assert.Contains(main.EntryNode.Outputs[0], newNode.Inputs[0].Connections); + Assert.Equal("capacity", newNode.Inputs[1].Name); + graphCanvas.Received(1).Refresh(newNode); + } + + [Fact] + public void AddEnumerableRange_ShouldExposeAllPortsWhenAddedToCanvas() + { + var project = Project.CreateNewDefaultProject(out var main); + var graphCanvas = Substitute.For(); + graphCanvas.Graph.Returns(main.Graph); + main.Graph.GraphCanvas = graphCanvas; + + string[]? inputsSeenByCanvas = null; + string[]? outputsSeenByCanvas = null; + graphCanvas + .When(canvas => canvas.AddNode(Arg.Any())) + .Do(call => + { + var addedNode = call.Arg(); + inputsSeenByCanvas = addedNode.Inputs.Select(connection => connection.Name).ToArray(); + outputsSeenByCanvas = addedNode.Outputs.Select(connection => connection.Name).ToArray(); + }); + + var rangeSearchResult = Assert.Single( + NodeProvider.Search(main.Graph, "Enumerable.Range", null, null) + .OfType()); + + var rangeNode = Assert.IsType(main.Graph.Manager.AddNode(rangeSearchResult, _ => { })); + + Assert.NotNull(inputsSeenByCanvas); + Assert.NotNull(outputsSeenByCanvas); + Assert.Equal(["Exec", "start", "count"], inputsSeenByCanvas); + Assert.Equal(["Exec", "Result"], outputsSeenByCanvas); + Assert.Equal(inputsSeenByCanvas, rangeNode.Inputs.Select(connection => connection.Name)); + Assert.Equal(outputsSeenByCanvas, rangeNode.Outputs.Select(connection => connection.Name)); + } + [Fact] public void ConnectTwoExecInOneOutput_ShouldDisconnectFirstExec() { diff --git a/src/NodeDev.Tests/GraphPortModelTests.cs b/src/NodeDev.Tests/GraphPortModelTests.cs new file mode 100644 index 00000000..495c8ffb --- /dev/null +++ b/src/NodeDev.Tests/GraphPortModelTests.cs @@ -0,0 +1,81 @@ +using Blazor.Diagrams.Core.Geometry; +using Blazor.Diagrams.Core.Models; +using NodeDev.Blazor.DiagramsModels; +using NodeDev.Core.Nodes.Delegates; + +namespace NodeDev.Tests; + +public class GraphPortModelTests +{ + [Fact] + public void ImplicitReturnCanBeProjectedAsAGroupPort() + { + var graph = GraphExecutorTests.CreateSimpleAddGraph(out _, out _, out _); + var func = (CreateFuncNode)graph.Manager.AddNode( + new NodeDev.Core.NodeProvider.NodeSearchResult(typeof(CreateFuncNode)), + _ => { }, + callableScopeId: null); + func.SetResultType(graph.Project.TypeFactory.Get()); + func.AddParameter("value", graph.Project.TypeFactory.Get()); + + var entry = Assert.Single(graph.GetNodesInScope(func.BodyScopeId).OfType()); + var lambdaReturn = Assert.Single(graph.GetNodesInScope(func.BodyScopeId).OfType()); + var group = new LambdaGroupModel(func); + var sourceNode = new GraphNodeModel(entry); + var sourcePort = new GraphPortModel(sourceNode, entry.ParameterOutputs[0], isInput: false); + var destinationPort = new GraphPortModel(group, lambdaReturn.ResultInput, isInput: true); + + Assert.Same(lambdaReturn, group.BoundaryReturn); + Assert.Equal(LambdaGroupModel.FuncPadding, group.Padding); + Assert.Equal(LambdaGroupModel.MinimumWidth, group.Size!.Width); + Assert.Equal(LambdaGroupModel.MinimumHeight, group.Size.Height); + Assert.Same(group, destinationPort.Parent); + Assert.True(sourcePort.CanAttachTo(destinationPort)); + Assert.True(destinationPort.CanAttachTo(sourcePort)); + } + + [Fact] + public void MinimumSizeGroup_KeepsWorkspaceWhileChildrenMoveInsideIt() + { + var graph = GraphExecutorTests.CreateSimpleAddGraph(out _, out _, out _); + var func = (CreateFuncNode)graph.Manager.AddNode( + new NodeDev.Core.NodeProvider.NodeSearchResult(typeof(CreateFuncNode)), + _ => { }, + callableScopeId: null); + var group = new LambdaGroupModel(func); + var child = new NodeModel(new Point(180, 180)) { Size = new Size(100, 80) }; + + group.AddChild(child); + child.SetPosition(400, 250); + + Assert.Equal(0, group.Position.X); + Assert.Equal(0, group.Position.Y); + Assert.Equal(600, group.Size!.Width); + Assert.Equal(420, group.Size.Height); + + child.SetPosition(520, 250); + + Assert.Equal(520 + 100 + (double)LambdaGroupModel.FuncPadding, group.Size.Width); + Assert.Equal(420, group.Size.Height); + } + + [Fact] + public void DataOutputCanAttachFromEnclosingScopeIntoLambda() + { + var graph = GraphExecutorTests.CreateSimpleAddGraph(out var entry, out _, out _); + var func = (CreateFuncNode)graph.Manager.AddNode( + new NodeDev.Core.NodeProvider.NodeSearchResult(typeof(CreateFuncNode)), + _ => { }, + callableScopeId: null); + func.SetResultType(graph.Project.TypeFactory.Get()); + var lambdaReturn = Assert.Single(graph.GetNodesInScope(func.BodyScopeId).OfType()); + + var sourceNode = new GraphNodeModel(entry); + var destinationNode = new GraphNodeModel(lambdaReturn); + var sourcePort = new GraphPortModel(sourceNode, entry.Outputs[1], isInput: false); + var destinationPort = new GraphPortModel(destinationNode, lambdaReturn.ResultInput, isInput: true); + + Assert.True(sourcePort.CanAttachTo(destinationPort)); + Assert.True(destinationPort.CanAttachTo(sourcePort)); + } +} diff --git a/src/NodeDev.Tests/LambdaRegionTests.cs b/src/NodeDev.Tests/LambdaRegionTests.cs new file mode 100644 index 00000000..e7248512 --- /dev/null +++ b/src/NodeDev.Tests/LambdaRegionTests.cs @@ -0,0 +1,389 @@ +using NodeDev.Core; +using NodeDev.Core.Class; +using NodeDev.Core.Nodes; +using NodeDev.Core.Nodes.Delegates; +using NodeDev.Core.Nodes.Flow; +using NodeDev.Core.Nodes.Math; + +namespace NodeDev.Tests; + +public class LambdaRegionTests +{ + [Fact] + public void CreateFunc_DefaultsResultTypeToBool() + { + var (_, method, _) = CreateMethod("Run"); + var graph = method.Graph; + var func = AddDelegate(graph); + var lambdaReturn = Assert.Single(graph.GetNodesInScope(func.BodyScopeId).OfType()); + + Assert.Equal(graph.Project.TypeFactory.Get(), func.ResultType); + Assert.Equal(typeof(Func), func.DelegateType.MakeRealType()); + Assert.Equal(func.ResultType, lambdaReturn.ResultInput.Type); + } + + [Fact] + public void CreateFunc_CreatesScopedEntryAndReturn_AndProjectsSignature() + { + var (_, method, _) = CreateMethod("Run", "value"); + var graph = method.Graph; + var func = AddDelegate(graph); + + func.SetResultType(graph.Project.TypeFactory.Get()); + var parameter = func.AddParameter("item", graph.Project.TypeFactory.Get()); + var capture = func.AddCapture("prefix", graph.Project.TypeFactory.Get()); + + var body = graph.GetNodesInScope(func.BodyScopeId).ToList(); + var entry = Assert.Single(body.OfType()); + var lambdaReturn = Assert.Single(body.OfType()); + + Assert.Equal(func.Id, entry.CallableScopeId); + Assert.Equal(func.Id, lambdaReturn.CallableScopeId); + Assert.True(lambdaReturn.IsImplicit); + Assert.Equal("item", Assert.Single(entry.ParameterOutputs).Name); + Assert.Equal("Captured prefix", Assert.Single(entry.CaptureOutputs).Name); + Assert.Equal(parameter.Type, entry.ParameterOutputs[0].Type); + Assert.Equal(capture.Type, func.CaptureInputs[0].Type); + Assert.Equal(func.ResultType, lambdaReturn.ResultInput.Type); + Assert.Equal(typeof(Func), func.DelegateType.MakeRealType()); + } + + [Fact] + public void CrossScopeConnection_IsRejectedWithoutMutation() + { + var (_, method, entry) = CreateMethod("Run", "value"); + var graph = method.Graph; + var func = AddDelegate(graph); + func.SetResultType(graph.Project.TypeFactory.Get()); + var lambdaReturn = Assert.Single(graph.GetNodesInScope(func.BodyScopeId).OfType()); + + var error = Assert.Throws(() => + graph.Manager.AddNewConnectionBetween(entry.Outputs[1], lambdaReturn.ResultInput)); + + Assert.Contains("scope boundary", error.Message, StringComparison.OrdinalIgnoreCase); + Assert.Empty(entry.Outputs[1].Connections); + Assert.Empty(lambdaReturn.ResultInput.Connections); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void FuncParameter_CanBeInvoked_AndDoesNotReturnFromContainingMethod(bool debug) + { + var (project, method, entry) = CreateMethod("Double", "value"); + var graph = method.Graph; + var methodReturn = Assert.Single(method.ReturnNodes); + var func = AddDelegate(graph); + func.SetResultType(graph.Project.TypeFactory.Get()); + func.AddParameter("item", graph.Project.TypeFactory.Get()); + + var lambdaEntry = Assert.Single(graph.GetNodesInScope(func.BodyScopeId).OfType()); + var lambdaReturn = Assert.Single(graph.GetNodesInScope(func.BodyScopeId).OfType()); + var add = new Add(graph); + graph.Manager.AddNode(add, func.BodyScopeId); + graph.Manager.AddNewConnectionBetween(lambdaEntry.ParameterOutputs[0], add.Inputs[0]); + graph.Manager.AddNewConnectionBetween(lambdaEntry.ParameterOutputs[0], add.Inputs[1]); + graph.Manager.AddNewConnectionBetween(add.Outputs[0], lambdaReturn.ResultInput); + + var invoke = AddTypedInvoke(graph, func.DelegateType); + graph.Manager.AddNewConnectionBetween(func.DelegateOutput, invoke.DelegateInput); + graph.Manager.AddNewConnectionBetween(entry.Outputs[1], invoke.InvocationInputs[0]); + graph.Manager.AddNewConnectionBetween(entry.Outputs[0], invoke.Inputs[0]); + graph.Manager.AddNewConnectionBetween(invoke.Outputs[0], methodReturn.Inputs[0]); + graph.Manager.AddNewConnectionBetween(invoke.ResultOutput!, methodReturn.Inputs[^1]); + + var result = new RoslynNodeClassCompiler(project, debug ? BuildOptions.Debug : BuildOptions.Release).Compile(); + var generatedMethod = result.Assembly.GetType("LambdaTests.TestClass")!.GetMethod("Double")!; + + Assert.Equal(12, generatedMethod.Invoke(null, [6])); + Assert.Contains("global::System.Func", result.SourceCode); + Assert.Contains("(int item) =>", result.SourceCode); + } + + [Fact] + public void CrossScopeDataConnection_AutomaticallyCreatesSnapshottedCapture() + { + var (project, method, entry) = CreateMethod("Capture", "value"); + var graph = method.Graph; + var methodReturn = Assert.Single(method.ReturnNodes); + var func = AddDelegate(graph); + func.SetResultType(graph.Project.TypeFactory.Get()); + + var lambdaEntry = Assert.Single(graph.GetNodesInScope(func.BodyScopeId).OfType()); + var lambdaReturn = Assert.Single(graph.GetNodesInScope(func.BodyScopeId).OfType()); + graph.Manager.AddNewConnectionBetweenOrCapture(entry.Outputs[1], lambdaReturn.ResultInput); + + Assert.Equal("value", Assert.Single(func.Captures).Name, ignoreCase: true); + Assert.Contains(func.CaptureInputs[0], entry.Outputs[1].Connections); + Assert.Contains(lambdaReturn.ResultInput, lambdaEntry.CaptureOutputs[0].Connections); + + var invoke = AddTypedInvoke(graph, func.DelegateType); + graph.Manager.AddNewConnectionBetween(func.DelegateOutput, invoke.DelegateInput); + graph.Manager.AddNewConnectionBetween(entry.Outputs[0], invoke.Inputs[0]); + graph.Manager.AddNewConnectionBetween(invoke.Outputs[0], methodReturn.Inputs[0]); + graph.Manager.AddNewConnectionBetween(invoke.ResultOutput!, methodReturn.Inputs[^1]); + + var result = new RoslynNodeClassCompiler(project, BuildOptions.Release).Compile(); + var generatedMethod = result.Assembly.GetType("LambdaTests.TestClass")!.GetMethod("Capture")!; + + Assert.Equal(42, generatedMethod.Invoke(null, [42])); + Assert.Contains("lambdaCapture_value", result.SourceCode); + } + + [Fact] + public void Action_CanBeCreatedAndInvoked() + { + var project = new Project(Guid.NewGuid()); + var nodeClass = new NodeClass("TestClass", "LambdaTests", project); + project.AddClass(nodeClass); + var method = new NodeClassMethod(nodeClass, "RunAction", project.TypeFactory.Void) { IsStatic = true }; + nodeClass.AddMethod(method, createEntryAndReturn: false); + method.Parameters.Add(new NodeClassMethodParameter("value", project.TypeFactory.Get(), method)); + var entry = new EntryNode(method.Graph); + var methodReturn = new ReturnNode(method.Graph); + method.Graph.Manager.AddNode(entry); + method.Graph.Manager.AddNode(methodReturn); + var action = AddDelegate(method.Graph); + action.AddParameter("value", project.TypeFactory.Get()); + var invoke = AddTypedInvoke(method.Graph, action.DelegateType); + + method.Graph.Manager.AddNewConnectionBetween(action.DelegateOutput, invoke.DelegateInput); + method.Graph.Manager.AddNewConnectionBetween(entry.Outputs[1], invoke.InvocationInputs[0]); + method.Graph.Manager.AddNewConnectionBetween(entry.Outputs[0], invoke.Inputs[0]); + method.Graph.Manager.AddNewConnectionBetween(invoke.Outputs[0], methodReturn.Inputs[0]); + + var result = new RoslynNodeClassCompiler(project, BuildOptions.Release).Compile(); + var generatedMethod = result.Assembly.GetType("LambdaTests.TestClass")!.GetMethod("RunAction")!; + generatedMethod.Invoke(null, [42]); + + Assert.Contains("global::System.Action", result.SourceCode); + Assert.DoesNotContain("Invoke_Delegate_Result", result.SourceCode); + } + + [Fact] + public void FuncBranch_AllowsMultipleLambdaReturns() + { + var (project, method, entry) = CreateMethod("Choose", "flag"); + var graph = method.Graph; + var methodReturn = Assert.Single(method.ReturnNodes); + var func = AddDelegate(graph); + func.SetResultType(graph.Project.TypeFactory.Get()); + func.AddParameter("flag", graph.Project.TypeFactory.Get()); + var lambdaEntry = Assert.Single(graph.GetNodesInScope(func.BodyScopeId).OfType()); + var returnTrue = Assert.Single(graph.GetNodesInScope(func.BodyScopeId).OfType()); + returnTrue.ResultInput.UpdateTextboxText("1"); + var returnFalse = new LambdaReturnNode(graph); + Assert.False(returnFalse.IsImplicit); + returnFalse.ResultInput.UpdateTypeAndTextboxVisibility(graph.Project.TypeFactory.Get(), overrideInitialType: true); + returnFalse.ResultInput.UpdateTextboxText("2"); + graph.Manager.AddNode(returnFalse, func.BodyScopeId); + var branch = new Branch(graph); + graph.Manager.AddNode(branch, func.BodyScopeId); + + graph.Manager.AddNewConnectionBetween(lambdaEntry.ExecOutput, branch.Inputs[0]); + graph.Manager.AddNewConnectionBetween(lambdaEntry.ParameterOutputs[0], branch.Inputs[1]); + graph.Manager.AddNewConnectionBetween(branch.Outputs[0], returnTrue.ExecInput); + graph.Manager.AddNewConnectionBetween(branch.Outputs[1], returnFalse.ExecInput); + + var invoke = AddTypedInvoke(graph, func.DelegateType); + graph.Manager.AddNewConnectionBetween(func.DelegateOutput, invoke.DelegateInput); + graph.Manager.AddNewConnectionBetween(entry.Outputs[1], invoke.InvocationInputs[0]); + graph.Manager.AddNewConnectionBetween(entry.Outputs[0], invoke.Inputs[0]); + graph.Manager.AddNewConnectionBetween(invoke.Outputs[0], methodReturn.Inputs[0]); + graph.Manager.AddNewConnectionBetween(invoke.ResultOutput!, methodReturn.Inputs[^1]); + + var result = new RoslynNodeClassCompiler(project, BuildOptions.Release).Compile(); + var generatedMethod = result.Assembly.GetType("LambdaTests.TestClass")!.GetMethod("Choose")!; + Assert.Equal(1, generatedMethod.Invoke(null, [true])); + Assert.Equal(2, generatedMethod.Invoke(null, [false])); + } + + [Fact] + public void NestedFunc_CompilesAndRuns() + { + var (project, method, entry) = CreateMethod("Nested"); + var graph = method.Graph; + var methodReturn = Assert.Single(method.ReturnNodes); + var outer = AddDelegate(graph); + outer.SetResultType(graph.Project.TypeFactory.Get()); + var outerEntry = Assert.Single(graph.GetNodesInScope(outer.BodyScopeId).OfType()); + var outerReturn = Assert.Single(graph.GetNodesInScope(outer.BodyScopeId).OfType()); + + var inner = AddDelegate(graph, outer.BodyScopeId); + inner.SetResultType(graph.Project.TypeFactory.Get()); + var innerReturn = Assert.Single(graph.GetNodesInScope(inner.BodyScopeId).OfType()); + innerReturn.ResultInput.UpdateTextboxText("7"); + + var invokeInner = AddTypedInvoke(graph, inner.DelegateType, outer.BodyScopeId); + graph.Manager.AddNewConnectionBetween(inner.DelegateOutput, invokeInner.DelegateInput); + graph.Manager.AddNewConnectionBetween(outerEntry.ExecOutput, invokeInner.Inputs[0]); + graph.Manager.AddNewConnectionBetween(invokeInner.Outputs[0], outerReturn.ExecInput); + graph.Manager.AddNewConnectionBetween(invokeInner.ResultOutput!, outerReturn.ResultInput); + + var invokeOuter = AddTypedInvoke(graph, outer.DelegateType); + graph.Manager.AddNewConnectionBetween(outer.DelegateOutput, invokeOuter.DelegateInput); + graph.Manager.AddNewConnectionBetween(entry.Outputs[0], invokeOuter.Inputs[0]); + graph.Manager.AddNewConnectionBetween(invokeOuter.Outputs[0], methodReturn.Inputs[0]); + graph.Manager.AddNewConnectionBetween(invokeOuter.ResultOutput!, methodReturn.Inputs[^1]); + + var result = new RoslynNodeClassCompiler(project, BuildOptions.Release).Compile(); + var generatedMethod = result.Assembly.GetType("LambdaTests.TestClass")!.GetMethod("Nested")!; + + Assert.Equal(7, generatedMethod.Invoke(null, null)); + Assert.Equal(2, CountOccurrences(result.SourceCode, "global::System.Func")); + } + + [Fact] + public void AutomaticCapture_TraversesEveryNestedLambdaBoundary() + { + var (_, method, entry) = CreateMethod("NestedCapture", "value"); + var graph = method.Graph; + var outer = AddDelegate(graph); + outer.SetResultType(graph.Project.TypeFactory.Get()); + var inner = AddDelegate(graph, outer.BodyScopeId); + inner.SetResultType(graph.Project.TypeFactory.Get()); + var outerEntry = Assert.Single(graph.GetNodesInScope(outer.BodyScopeId).OfType()); + var innerEntry = Assert.Single(graph.GetNodesInScope(inner.BodyScopeId).OfType()); + var innerReturn = Assert.Single(graph.GetNodesInScope(inner.BodyScopeId).OfType()); + + graph.Manager.AddNewConnectionBetweenOrCapture(entry.Outputs[1], innerReturn.ResultInput); + + Assert.Single(outer.Captures); + Assert.Single(inner.Captures); + Assert.Contains(outer.CaptureInputs[0], entry.Outputs[1].Connections); + Assert.Contains(inner.CaptureInputs[0], outerEntry.CaptureOutputs[0].Connections); + Assert.Contains(innerReturn.ResultInput, innerEntry.CaptureOutputs[0].Connections); + } + + [Fact] + public void AutomaticCapture_RejectsSiblingScopesWithoutMutation() + { + var (_, method, _) = CreateMethod("SiblingCapture"); + var graph = method.Graph; + var left = AddDelegate(graph); + left.SetResultType(graph.Project.TypeFactory.Get()); + var right = AddDelegate(graph); + right.SetResultType(graph.Project.TypeFactory.Get()); + var leftEntry = Assert.Single(graph.GetNodesInScope(left.BodyScopeId).OfType()); + left.AddParameter("value", graph.Project.TypeFactory.Get()); + var rightReturn = Assert.Single(graph.GetNodesInScope(right.BodyScopeId).OfType()); + + Assert.Throws(() => + graph.Manager.AddNewConnectionBetweenOrCapture(leftEntry.ParameterOutputs[0], rightReturn.ResultInput)); + + Assert.Empty(left.Captures); + Assert.Empty(right.Captures); + Assert.Empty(leftEntry.ParameterOutputs[0].Connections); + Assert.Empty(rightReturn.ResultInput.Connections); + } + + [Fact] + public void LambdaGraph_RoundTripsAndStillRuns() + { + var (project, method, entry) = CreateMethod("RoundTrip", "value"); + var graph = method.Graph; + var methodReturn = Assert.Single(method.ReturnNodes); + var func = AddDelegate(graph); + func.SetResultType(graph.Project.TypeFactory.Get()); + func.AddCapture("captured", graph.Project.TypeFactory.Get()); + var lambdaEntry = Assert.Single(graph.GetNodesInScope(func.BodyScopeId).OfType()); + var lambdaReturn = Assert.Single(graph.GetNodesInScope(func.BodyScopeId).OfType()); + graph.Manager.AddNewConnectionBetween(lambdaEntry.CaptureOutputs[0], lambdaReturn.ResultInput); + graph.Manager.AddNewConnectionBetween(entry.Outputs[1], func.CaptureInputs[0]); + + var invoke = AddTypedInvoke(graph, func.DelegateType); + graph.Manager.AddNewConnectionBetween(func.DelegateOutput, invoke.DelegateInput); + graph.Manager.AddNewConnectionBetween(entry.Outputs[0], invoke.Inputs[0]); + graph.Manager.AddNewConnectionBetween(invoke.Outputs[0], methodReturn.Inputs[0]); + graph.Manager.AddNewConnectionBetween(invoke.ResultOutput!, methodReturn.Inputs[^1]); + + var serialized = project.Serialize(); + var restored = Project.Deserialize(serialized); + var restoredMethod = restored.Classes.Single().Methods.Single(); + var restoredFunc = Assert.Single(restoredMethod.Graph.Nodes.Values.OfType()); + var restoredReturn = Assert.Single(restoredMethod.Graph.GetNodesInScope(restoredFunc.BodyScopeId).OfType()); + Assert.Equal("captured", Assert.Single(restoredFunc.Captures).Name); + Assert.True(restoredReturn.IsImplicit); + Assert.All(restoredMethod.Graph.GetNodesInScope(restoredFunc.BodyScopeId), node => Assert.Equal(restoredFunc.Id, node.CallableScopeId)); + + var result = new RoslynNodeClassCompiler(restored, BuildOptions.Release).Compile(); + var generatedMethod = result.Assembly.GetType("LambdaTests.TestClass")!.GetMethod("RoundTrip")!; + Assert.Equal(9, generatedMethod.Invoke(null, [9])); + } + + [Fact] + public void RemovingLambda_RemovesNestedScopesButPreservesRootSiblings() + { + var (_, method, entry) = CreateMethod("Delete"); + var graph = method.Graph; + var outer = AddDelegate(graph); + outer.SetResultType(graph.Project.TypeFactory.Get()); + var inner = AddDelegate(graph, outer.BodyScopeId); + var rootSibling = new Add(graph); + graph.Manager.AddNode(rootSibling); + + graph.Manager.RemoveNode(outer); + + Assert.DoesNotContain(outer, graph.Nodes.Values); + Assert.DoesNotContain(inner, graph.Nodes.Values); + Assert.DoesNotContain(graph.Nodes.Values, node => node.CallableScopeId == outer.Id || node.CallableScopeId == inner.Id); + Assert.Contains(entry, graph.Nodes.Values); + Assert.Contains(rootSibling, graph.Nodes.Values); + } + + private static T AddDelegate(Graph graph, string? scopeId = null) where T : CreateDelegateNode + { + return (T)graph.Manager.AddNode(new NodeProvider.NodeSearchResult(typeof(T)), _ => { }, scopeId); + } + + private static InvokeDelegateNode AddTypedInvoke(Graph graph, NodeDev.Core.Types.TypeBase delegateType, string? scopeId = null) + { + return (InvokeDelegateNode)graph.Manager.AddNode( + new NodeProvider.DelegateInvocationNode(typeof(InvokeDelegateNode), delegateType), + _ => { }, + scopeId); + } + + private static (Project Project, NodeClassMethod Method, EntryNode Entry) CreateMethod(string name) + { + var project = new Project(Guid.NewGuid()); + var nodeClass = new NodeClass("TestClass", "LambdaTests", project); + project.AddClass(nodeClass); + var method = new NodeClassMethod(nodeClass, name, project.TypeFactory.Get()) { IsStatic = true }; + nodeClass.AddMethod(method, createEntryAndReturn: false); + var entry = new EntryNode(method.Graph); + var methodReturn = new ReturnNode(method.Graph); + method.Graph.Manager.AddNode(entry); + method.Graph.Manager.AddNode(methodReturn); + method.Graph.Manager.AddNewConnectionBetween(entry.Outputs[0], methodReturn.Inputs[0]); + return (project, method, entry); + } + + private static (Project Project, NodeClassMethod Method, EntryNode Entry) CreateMethod(string name, string parameterName) + { + var project = new Project(Guid.NewGuid()); + var nodeClass = new NodeClass("TestClass", "LambdaTests", project); + project.AddClass(nodeClass); + var method = new NodeClassMethod(nodeClass, name, project.TypeFactory.Get()) { IsStatic = true }; + nodeClass.AddMethod(method, createEntryAndReturn: false); + method.Parameters.Add(new NodeClassMethodParameter(parameterName, project.TypeFactory.Get(), method)); + var entry = new EntryNode(method.Graph); + var methodReturn = new ReturnNode(method.Graph); + method.Graph.Manager.AddNode(entry); + method.Graph.Manager.AddNode(methodReturn); + method.Graph.Manager.AddNewConnectionBetween(entry.Outputs[0], methodReturn.Inputs[0]); + return (project, method, entry); + } + + private static int CountOccurrences(string text, string value) + { + var count = 0; + var index = 0; + while ((index = text.IndexOf(value, index, StringComparison.Ordinal)) >= 0) + { + count++; + index += value.Length; + } + return count; + } +} diff --git a/src/NodeDev.Tests/NodeProviderTests.cs b/src/NodeDev.Tests/NodeProviderTests.cs index e4231a8a..d7890582 100644 --- a/src/NodeDev.Tests/NodeProviderTests.cs +++ b/src/NodeDev.Tests/NodeProviderTests.cs @@ -1,4 +1,6 @@ using NodeDev.Core; +using NodeDev.Core.Nodes; +using NodeDev.Core.Nodes.Math; namespace NodeDev.Tests; @@ -21,4 +23,57 @@ public void TestsNodeMethod() Assert.DoesNotContain(methods, x => x is NodeProvider.MethodCallNode methodCall && methodCall.MethodInfo == graph.SelfMethod); } -} \ No newline at end of file + [Fact] + public void ScopeAwareSearchReturnsNodesInRootScope() + { + var graph = GraphExecutorTests.CreateSimpleAddGraph(out _, out _, out _); + + var results = NodeProvider.Search(graph, "Add", null, null).ToList(); + + Assert.Contains(results, result => result.Type == typeof(Add)); + } + + [Fact] + public void SearchFromIEnumerableIntReturnsClosedWhereExtensionMethod() + { + var project = Project.CreateNewDefaultProject(out var main); + var sourceNode = new New(main.Graph); + var enumerableOfInt = project.TypeFactory.Get>(); + sourceNode.Outputs[1].UpdateTypeAndTextboxVisibility(enumerableOfInt, overrideInitialType: true); + main.Graph.Manager.AddNode(sourceNode); + + var predicateType = project.TypeFactory.Get>(); + Assert.DoesNotContain( + NodeProvider.Search(main.Graph, "MethodThatDoesNotExist", sourceNode.Outputs[1], null), + result => result is NodeProvider.MethodCallNode); + var whereResults = + NodeProvider.Search(main.Graph, "Where", sourceNode.Outputs[1], null) + .OfType() + .ToList(); + var whereResult = Assert.Single( + whereResults, + result => + result.MethodInfo.DeclaringType.FullName == typeof(Enumerable).FullName && + result.MethodInfo.GetParameters().ElementAt(1).ParameterType == predicateType); + + Assert.Equal(enumerableOfInt, whereResult.MethodInfo.GetParameters().First().ParameterType); + Assert.Equal(enumerableOfInt, whereResult.MethodInfo.ReturnType); + Assert.False(whereResult.MethodInfo.CreateMethodInfo().ContainsGenericParameters); + + var whereNode = Assert.IsType(main.Graph.Manager.AddNode(whereResult, _ => { })); + Assert.Equal(enumerableOfInt, whereNode.Inputs[1].Type); + Assert.Equal(predicateType, whereNode.Inputs[2].Type); + Assert.Equal(enumerableOfInt, whereNode.Outputs[1].Type); + Assert.Equal(2, whereNode.AlternatesOverloads.Count()); + main.Graph.Manager.AddNewConnectionBetween(sourceNode.Outputs[1], whereNode.Inputs[1]); + Assert.Contains(sourceNode.Outputs[1], whereNode.Inputs[1].Connections); + + var restoredProject = Project.Deserialize(project.Serialize()); + var restoredWhereNode = Assert.Single(restoredProject.GetNodes(), node => node.TargetMethod?.Name == "Where"); + Assert.Equal(restoredProject.TypeFactory.Get>(), restoredWhereNode.Inputs[1].Type); + Assert.Equal(restoredProject.TypeFactory.Get>(), restoredWhereNode.Inputs[2].Type); + Assert.Single(restoredWhereNode.Inputs[1].Connections); + Assert.False(restoredWhereNode.TargetMethod!.CreateMethodInfo().ContainsGenericParameters); + } + +}