Static utility class for analyzing prism editor graphs. It provides graph queries: resolving ports/nodes, enumerating incoming/outgoing edges, finding outputs/terminals, reachability, topological ordering, cycle detection and description, orphan and dependency queries, and static validation of graph correctness.
using Editor.Prism.Core;
namespace Editor.Prism.Model;
/// <summary>
/// Read-only analysis of a document: reachability, topological order, real cycle detection,
/// dependency subtrees and orphan detection.
/// <para>
/// Every traversal here is iterative rather than recursive, so a pathological graph produces a
/// diagnostic instead of a stack overflow, and <b>every traversal includes reroute nodes</b>. The
/// built-in editor exempts reroutes from its cycle check, which is why a reroute loop can hang it;
/// treating every node identically is both simpler and correct.
/// </para>
/// </summary>
public static class GraphQueries
{
/// <summary>The node a port reference points at. Null when it does not resolve.</summary>
public static PrismNode NodeOf( IPrismGraph graph, PortRef reference ) => graph?.FindNode( reference.Node );
/// <summary>Resolve a port reference to a live port.</summary>
public static bool TryGetPort( IPrismGraph graph, PortRef reference, out Port port )
{
port = graph?.FindNode( reference.Node )?.FindPort( reference.Port );
return port is not null;
}
/// <summary>Every edge terminating on a node.</summary>
public static IEnumerable<Edge> IncomingEdges( IPrismGraph graph, NodeId node )
{
if ( graph?.Edges is null ) yield break;
foreach ( var edge in graph.Edges )
{
if ( edge is not null && edge.ToNode == node ) yield return edge;
}
}
/// <summary>Every edge leaving a node.</summary>
public static IEnumerable<Edge> OutgoingEdges( IPrismGraph graph, NodeId node )
{
if ( graph?.Edges is null ) yield break;
foreach ( var edge in graph.Edges )
{
if ( edge is not null && edge.FromNode == node ) yield return edge;
}
}
/// <summary>Every node that directly feeds this one.</summary>
public static IEnumerable<NodeId> Predecessors( IPrismGraph graph, NodeId node ) =>
IncomingEdges( graph, node ).Select( x => x.FromNode ).Distinct();
/// <summary>Every node this one directly feeds.</summary>
public static IEnumerable<NodeId> Successors( IPrismGraph graph, NodeId node ) =>
OutgoingEdges( graph, node ).Select( x => x.ToNode ).Distinct();
/// <summary>
/// The nodes a compile starts from: registered output nodes when there are any, otherwise every
/// node with no outgoing edge. The fallback is what makes a half-built graph still previewable.
/// </summary>
public static IReadOnlyList<NodeId> OutputNodes( IPrismGraph graph )
{
if ( graph?.Nodes is null ) return Array.Empty<NodeId>();
var outputs = new List<NodeId>();
foreach ( var node in graph.Nodes )
{
if ( node is null ) continue;
if ( !IsOutputNode( node ) ) continue;
outputs.Add( node.Id );
}
if ( outputs.Count > 0 ) return outputs;
return TerminalNodes( graph );
}
/// <summary>True when a node looks like a graph terminal: an output-category node with no outputs.</summary>
public static bool IsOutputNode( PrismNode node )
{
if ( node is null ) return false;
if ( node.Outputs.Count > 0 ) return false;
var id = node.Descriptor?.Id;
if ( !string.IsNullOrEmpty( id ) && id.StartsWith( "prism.output.", StringComparison.OrdinalIgnoreCase ) )
{
return true;
}
var category = node.Descriptor?.Category;
return !string.IsNullOrEmpty( category ) &&
category.StartsWith( "Output", StringComparison.OrdinalIgnoreCase );
}
/// <summary>Every node with no outgoing edge.</summary>
public static IReadOnlyList<NodeId> TerminalNodes( IPrismGraph graph )
{
if ( graph?.Nodes is null ) return Array.Empty<NodeId>();
var hasOutgoing = new HashSet<NodeId>();
foreach ( var edge in graph.Edges ?? Array.Empty<Edge>() )
{
if ( edge is not null ) hasOutgoing.Add( edge.FromNode );
}
var result = new List<NodeId>();
foreach ( var node in graph.Nodes )
{
if ( node is null || hasOutgoing.Contains( node.Id ) ) continue;
result.Add( node.Id );
}
return result;
}
/// <summary>
/// Every node reachable by walking <em>backwards</em> from the given roots — that is, everything
/// that contributes to the roots' values. Disabled nodes stop the walk, because a disabled node
/// falls back to inline values and its inputs are not evaluated.
/// </summary>
public static IReadOnlyCollection<NodeId> Reachable( IPrismGraph graph, IEnumerable<NodeId> roots,
bool stopAtDisabled = true )
{
var visited = new HashSet<NodeId>();
if ( graph is null || roots is null ) return visited;
var stack = new Stack<NodeId>();
foreach ( var root in roots )
{
if ( root.IsValid && visited.Add( root ) ) stack.Push( root );
}
var incoming = BuildIncomingMap( graph );
while ( stack.Count > 0 )
{
var current = stack.Pop();
if ( stopAtDisabled && IsDisabled( graph, current ) ) continue;
if ( !incoming.TryGetValue( current, out var sources ) ) continue;
foreach ( var source in sources )
{
if ( visited.Add( source ) ) stack.Push( source );
}
}
return visited;
}
/// <summary>Every node reachable backwards from the graph's output nodes.</summary>
public static IReadOnlyCollection<NodeId> ReachableFromOutputs( IPrismGraph graph ) =>
Reachable( graph, OutputNodes( graph ) );
/// <summary>
/// Nodes that contribute to nothing: not reachable backwards from any output and not an output
/// themselves. Purely informational — an orphan is a perfectly legal work in progress.
/// </summary>
public static IReadOnlyList<NodeId> Orphans( IPrismGraph graph, IEnumerable<NodeId> roots = null )
{
if ( graph?.Nodes is null ) return Array.Empty<NodeId>();
var reachable = Reachable( graph, roots ?? OutputNodes( graph ), false );
var result = new List<NodeId>();
foreach ( var node in graph.Nodes )
{
if ( node is null || reachable.Contains( node.Id ) ) continue;
result.Add( node.Id );
}
return result;
}
/// <summary>
/// Every node the given node depends on, including itself, in dependency-first order. This is the
/// subtree a "compile just this node" preview needs.
/// </summary>
public static IReadOnlyList<NodeId> DependencySubtree( IPrismGraph graph, NodeId node )
{
var subtree = Reachable( graph, new[] { node }, false );
return TopologicalOrder( graph, new[] { node } ).Where( subtree.Contains ).ToArray();
}
/// <summary>Every node that depends, directly or transitively, on the given node.</summary>
public static IReadOnlyList<NodeId> Dependents( IPrismGraph graph, NodeId node )
{
var visited = new HashSet<NodeId>();
if ( graph is null || !node.IsValid ) return Array.Empty<NodeId>();
var outgoing = BuildOutgoingMap( graph );
var stack = new Stack<NodeId>();
stack.Push( node );
while ( stack.Count > 0 )
{
var current = stack.Pop();
if ( !outgoing.TryGetValue( current, out var targets ) ) continue;
foreach ( var target in targets )
{
if ( visited.Add( target ) ) stack.Push( target );
}
}
return visited.ToArray();
}
/// <summary>
/// Dependency-first order over the nodes reachable from <paramref name="roots"/>, or over the whole
/// document when roots are omitted. Nodes involved in a cycle are appended at the end rather than
/// dropped, so a cyclic graph still produces a usable ordering for the UI.
/// </summary>
public static IReadOnlyList<NodeId> TopologicalOrder( IPrismGraph graph, IEnumerable<NodeId> roots = null )
{
if ( graph?.Nodes is null ) return Array.Empty<NodeId>();
var scope = roots is null
? new HashSet<NodeId>( graph.Nodes.Where( x => x is not null ).Select( x => x.Id ) )
: new HashSet<NodeId>( Reachable( graph, roots, false ) );
if ( scope.Count == 0 ) return Array.Empty<NodeId>();
var incoming = BuildIncomingMap( graph );
var order = new List<NodeId>( scope.Count );
var state = new Dictionary<NodeId, byte>( scope.Count );
// Iterative post-order DFS. 0 = unvisited, 1 = on the stack (grey), 2 = emitted (black).
var work = new Stack<(NodeId Node, int Index)>();
foreach ( var root in Ordered( graph, scope ) )
{
if ( state.TryGetValue( root, out var seen ) && seen == 2 ) continue;
work.Push( (root, 0) );
state[root] = 1;
while ( work.Count > 0 )
{
var (node, index) = work.Pop();
var sources = incoming.TryGetValue( node, out var list ) ? list : s_noIds;
if ( index < sources.Count )
{
work.Push( (node, index + 1) );
var source = sources[index];
if ( !scope.Contains( source ) ) continue;
state.TryGetValue( source, out var sourceState );
if ( sourceState == 0 )
{
state[source] = 1;
work.Push( (source, 0) );
}
continue;
}
state[node] = 2;
order.Add( node );
}
}
// Anything still grey belongs to a cycle: emit it so callers see every node exactly once.
foreach ( var node in Ordered( graph, scope ) )
{
if ( state.TryGetValue( node, out var seen ) && seen == 2 ) continue;
order.Add( node );
state[node] = 2;
}
return order;
}
/// <summary>
/// Find one cycle, reporting the full path in traversal order. Reroutes participate exactly like
/// any other node. Returns false when the document is acyclic.
/// </summary>
public static bool TryFindCycle( IPrismGraph graph, out IReadOnlyList<NodeId> cycle )
{
var cycles = FindCycles( graph, 1 );
cycle = cycles.Count > 0 ? cycles[0] : Array.Empty<NodeId>();
return cycles.Count > 0;
}
/// <summary>
/// Find up to <paramref name="limit"/> distinct cycles, each reported as the full node path with
/// the entry node repeated at the end so the loop reads naturally in a diagnostic.
/// </summary>
public static IReadOnlyList<IReadOnlyList<NodeId>> FindCycles( IPrismGraph graph, int limit = 8 )
{
var found = new List<IReadOnlyList<NodeId>>();
if ( graph?.Nodes is null ) return found;
var outgoing = BuildOutgoingMap( graph );
var state = new Dictionary<NodeId, byte>();
var path = new List<NodeId>();
var onPath = new HashSet<NodeId>();
var seenCycles = new HashSet<string>();
foreach ( var start in graph.Nodes.Where( x => x is not null ).Select( x => x.Id ) )
{
if ( state.TryGetValue( start, out var seen ) && seen == 2 ) continue;
if ( found.Count >= limit ) break;
var work = new Stack<(NodeId Node, int Index)>();
work.Push( (start, 0) );
while ( work.Count > 0 )
{
var (node, index) = work.Pop();
if ( index == 0 )
{
state[node] = 1;
path.Add( node );
onPath.Add( node );
}
var targets = outgoing.TryGetValue( node, out var list ) ? list : s_noIds;
if ( index < targets.Count )
{
work.Push( (node, index + 1) );
var next = targets[index];
if ( onPath.Contains( next ) )
{
var at = path.LastIndexOf( next );
if ( at >= 0 && found.Count < limit )
{
var loop = new List<NodeId>( path.Count - at + 1 );
for ( int i = at; i < path.Count; i++ )
{
loop.Add( path[i] );
}
loop.Add( next );
var key = string.Join( ">", loop.Select( x => x.Value ).OrderBy( x => x, StringComparer.Ordinal ) );
if ( seenCycles.Add( key ) ) found.Add( loop );
}
continue;
}
state.TryGetValue( next, out var nextState );
if ( nextState == 0 ) work.Push( (next, 0) );
continue;
}
state[node] = 2;
onPath.Remove( node );
if ( path.Count > 0 && path[^1] == node ) path.RemoveAt( path.Count - 1 );
}
path.Clear();
onPath.Clear();
}
return found;
}
/// <summary>
/// Would adding this connection close a loop? Answered without mutating the document, so the plug
/// setter can refuse a drop before anything changes.
/// </summary>
public static bool WouldCreateCycle( IPrismGraph graph, PortRef from, PortRef to )
{
if ( graph is null ) return false;
if ( !from.IsValid || !to.IsValid ) return false;
if ( from.Node == to.Node ) return true;
// The new edge runs from.Node -> to.Node. It closes a loop when from.Node is already
// reachable downstream of to.Node.
var outgoing = BuildOutgoingMap( graph );
var visited = new HashSet<NodeId> { to.Node };
var stack = new Stack<NodeId>();
stack.Push( to.Node );
while ( stack.Count > 0 )
{
var current = stack.Pop();
if ( current == from.Node ) return true;
if ( !outgoing.TryGetValue( current, out var targets ) ) continue;
foreach ( var target in targets )
{
if ( visited.Add( target ) ) stack.Push( target );
}
}
return false;
}
/// <summary>
/// How many nodes of a cycle path are named before the description gives up and counts the rest.
/// A cycle through a thousand nodes is not more informative than a cycle through twenty, and the
/// text ends up in a diagnostic detail body, a tooltip and a log line.
/// </summary>
public const int MaxDescribedCycleNodes = 24;
/// <summary>
/// Render a cycle path as <c>Title #id → Title #id → …</c> for a diagnostic detail body. Long cycles
/// are elided in the middle: the two ends are what identifies the loop, and the length is stated.
/// </summary>
public static string DescribeCycle( IPrismGraph graph, IReadOnlyList<NodeId> cycle )
{
if ( cycle is null || cycle.Count == 0 ) return string.Empty;
string Name( NodeId id )
{
var node = graph?.FindNode( id );
var title = node switch
{
UnknownNode unknown => unknown.DisplayTitle,
null => "<missing>",
_ => node.Descriptor?.Title ?? node.GetType().Name
};
return $"{title} #{id}";
}
if ( cycle.Count <= MaxDescribedCycleNodes )
{
return string.Join( " → ", cycle.Select( Name ) );
}
var head = MaxDescribedCycleNodes / 2;
var tail = MaxDescribedCycleNodes - head;
var parts = cycle.Take( head ).Select( Name ).ToList();
parts.Add( $"… {cycle.Count - MaxDescribedCycleNodes} more …" );
parts.AddRange( cycle.Skip( cycle.Count - tail ).Select( Name ) );
return string.Join( " → ", parts );
}
/// <summary>
/// The static checks that do not need the type solver: cycles, dangling edges, missing required
/// inputs, unresolved parameter references and a missing output node. Never throws; a node whose
/// <c>OnValidate</c> misbehaves is isolated and reported.
/// </summary>
public static IReadOnlyList<Diagnostic> Validate( IPrismGraph graph, DiagnosticSink sink = null )
{
var target = sink ?? new DiagnosticSink();
if ( graph is null ) return target.All;
foreach ( var cycle in FindCycles( graph ) )
{
target.Error( DiagnosticCode.Cycle, "This graph contains a cycle",
GraphRef.ForNode( cycle.Count > 0 ? cycle[0] : NodeId.None ), DescribeCycle( graph, cycle ) );
}
foreach ( var edge in graph.Edges ?? Array.Empty<Edge>() )
{
if ( edge is null ) continue;
if ( graph.FindNode( edge.FromNode ) is null || graph.FindNode( edge.ToNode ) is null )
{
target.Error( DiagnosticCode.DanglingEdge, "Connection references a node that does not exist",
GraphRef.ForEdge( edge.Id ), edge.ToString() );
}
}
foreach ( var node in graph.Nodes ?? Array.Empty<PrismNode>() )
{
if ( node is null ) continue;
foreach ( var input in node.Inputs )
{
if ( !input.Required ) continue;
if ( input.IsConnected ) continue;
if ( input.InlineValue is not null ) continue;
target.Error( DiagnosticCode.MissingInput,
$"'{input.DisplayName}' is required and has nothing connected",
GraphRef.ForPort( node.Id, input.Id ) );
}
var scoped = target.Scoped( GraphRef.ForNode( node.Id ) );
PrismLog.Try( $"Validate node {node.Id}",
() => node.OnValidate( new ValidationContext( node, graph, scoped ) ),
target, DiagnosticCode.NodeEmitFailed, GraphRef.ForNode( node.Id ) );
}
if ( OutputNodes( graph ).Count == 0 )
{
target.Error( DiagnosticCode.NoOutput, "This graph has no output node" );
}
return target.All;
}
static bool IsDisabled( IPrismGraph graph, NodeId id ) =>
graph?.FindNode( id ) is { } node && ( node.Flags & NodeFlags.Disabled ) != 0;
static IEnumerable<NodeId> Ordered( IPrismGraph graph, HashSet<NodeId> scope )
{
// Iterate in document order so the result is stable between runs, which is what makes
// regenerated shader text byte-identical for an unchanged graph.
foreach ( var node in graph.Nodes ?? Array.Empty<PrismNode>() )
{
if ( node is null || !scope.Contains( node.Id ) ) continue;
yield return node.Id;
}
}
static Dictionary<NodeId, List<NodeId>> BuildIncomingMap( IPrismGraph graph )
{
var map = new Dictionary<NodeId, List<NodeId>>();
foreach ( var edge in graph.Edges ?? Array.Empty<Edge>() )
{
if ( edge is null ) continue;
if ( !map.TryGetValue( edge.ToNode, out var list ) )
{
list = new List<NodeId>();
map[edge.ToNode] = list;
}
if ( !list.Contains( edge.FromNode ) ) list.Add( edge.FromNode );
}
return map;
}
static Dictionary<NodeId, List<NodeId>> BuildOutgoingMap( IPrismGraph graph )
{
var map = new Dictionary<NodeId, List<NodeId>>();
foreach ( var edge in graph.Edges ?? Array.Empty<Edge>() )
{
if ( edge is null ) continue;
if ( !map.TryGetValue( edge.FromNode, out var list ) )
{
list = new List<NodeId>();
map[edge.FromNode] = list;
}
if ( !list.Contains( edge.ToNode ) ) list.Add( edge.ToNode );
}
return map;
}
static readonly List<NodeId> s_noIds = new();
}