Commit: b6fe8b2
Parent: e558072

One-shot

Mårten Åsberg committed on 2026-10-29 at 19:22
Assisted-by: GitHub Copilot:gpt-6.1-sol
Directory.Packages.props +7 -0
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 8f45a42..6792d8a 100644
@@ -10,6 +10,13 @@
</GlobalPackageReference>
</ItemGroup>
<ItemGroup>
<PackageVersion Include="Avalonia" Version="11.3.22" />
<PackageVersion Include="Avalonia.Desktop" Version="11.3.22" />
<PackageVersion Include="Avalonia.Themes.Fluent" Version="11.3.22" />
<PackageVersion Include="Avalonia.Fonts.Inter" Version="11.3.22" />
<PackageVersion Include="Avalonia.Headless.XUnit" Version="11.3.22" />
<PackageVersion Include="NodifyAvalonia" Version="6.6.0" />
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.0" />
<PackageVersion Include="Glob" Version="1.1.9" />
<PackageVersion
Include="Microsoft.Extensions.Configuration.Json"
code-flow-analysis.slnx +4 -0
diff --git a/code-flow-analysis.slnx b/code-flow-analysis.slnx
index 4a8922c..4b5ab53 100644
@@ -6,4 +6,8 @@
<Project Path="core/src/Cfa.csproj" />
<Project Path="core/tests/Cfa.Tests.csproj" />
</Folder>
<Folder Name="/ui/">
<Project Path="ui/src/Cfa.Ui.csproj" />
<Project Path="ui/tests/Cfa.Ui.Tests.csproj" />
</Folder>
</Solution>
core/src/Analyzer.cs +1 -1
diff --git a/core/src/Analyzer.cs b/core/src/Analyzer.cs
index 237eb0b..405ebff 100644
@@ -40,9 +40,9 @@ public static class Analyzer
{
var builder = ImmutableDictionary.CreateBuilder<ISymbol, uint>(SymbolEqualityComparer.Default);
builder.Add(callGraph.Origin, 0);
AddVisits(callGraph.CallsTo);
AddVisits(callGraph.CalledFrom);
builder.Add(callGraph.Origin, 0);
return builder.ToImmutable();
core/tests/AnalyzerTests/AnalyzeShould.cs +37 -0
diff --git a/core/tests/AnalyzerTests/AnalyzeShould.cs b/core/tests/AnalyzerTests/AnalyzeShould.cs
new file mode 100644
index 0000000..ea98150
@@ -0,0 +1,37 @@
using System.Collections.Immutable;
using System.Linq;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Shouldly;
using Xunit;
namespace Cfa.Tests.AnalyzerTests;
public sealed class AnalyzeShould
{
[Fact]
public void KeepTheOriginWhenItsCallsLeadBackToIt()
{
// Arrange
var compilation = CSharpCompilation.Create(
"Example",
[CSharpSyntaxTree.ParseText("class Example { void Start() { Start(); } }")],
[MetadataReference.CreateFromFile(typeof(object).Assembly.Location)]
);
var origin = compilation.GetTypeByMetadataName("Example")!.GetMembers("Start").Single();
var calls = ImmutableDictionary.CreateBuilder<ISymbol, IImmutableList<Call>>(SymbolEqualityComparer.Default);
calls[origin] = [new(origin, LinkKind.Direct)];
var graph = new CallGraph(
origin,
calls.ToImmutable(),
ImmutableDictionary.Create<ISymbol, IImmutableList<Call>>(SymbolEqualityComparer.Default)
);
// Act
var analysis = Analyzer.Analyze(graph);
// Assert
analysis.Appearances[origin].ShouldBe(1u);
analysis.CallsTo[origin].ShouldHaveSingleItem().Target.ShouldBe(origin);
}
}
ui/src/App.axaml +17 -0
diff --git a/ui/src/App.axaml b/ui/src/App.axaml
new file mode 100644
index 0000000..460c831
@@ -0,0 +1,17 @@
<Application
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="Cfa.Ui.App"
RequestedThemeVariant="Dark"
>
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceInclude Source="avares://Nodify/Theme.axaml" />
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Application.Resources>
<Application.Styles>
<FluentTheme />
</Application.Styles>
</Application>
ui/src/App.axaml.cs +26 -0
diff --git a/ui/src/App.axaml.cs b/ui/src/App.axaml.cs
new file mode 100644
index 0000000..f3a8e25
@@ -0,0 +1,26 @@
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
using Cfa.Ui.Services;
using Cfa.Ui.ViewModels;
using Cfa.Ui.Views;
namespace Cfa.Ui;
public sealed partial class App : Application
{
public override void Initialize() => AvaloniaXamlLoader.Load(this);
public override void OnFrameworkInitializationCompleted()
{
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
var window = new MainWindow();
var model = new MainViewModel(new DesktopServices(window), new ScanSessionFactory());
window.DataContext = model;
window.Closed += (_, _) => model.Dispose();
desktop.MainWindow = window;
}
base.OnFrameworkInitializationCompleted();
}
}
ui/src/Cfa.Ui.csproj +18 -0
diff --git a/ui/src/Cfa.Ui.csproj b/ui/src/Cfa.Ui.csproj
new file mode 100644
index 0000000..0b6d0b3
@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<RootNamespace>Cfa.Ui</RootNamespace>
<AssemblyName>Cfa.Ui</AssemblyName>
<OutputType>WinExe</OutputType>
<IsTrimmable>false</IsTrimmable>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../core/src/Cfa.csproj" />
<PackageReference Include="Avalonia" />
<PackageReference Include="Avalonia.Desktop" />
<PackageReference Include="Avalonia.Themes.Fluent" />
<PackageReference Include="Avalonia.Fonts.Inter" />
<PackageReference Include="NodifyAvalonia" />
<PackageReference Include="CommunityToolkit.Mvvm" />
</ItemGroup>
</Project>
ui/src/Graph/GraphLayout.cs +247 -0
diff --git a/ui/src/Graph/GraphLayout.cs b/ui/src/Graph/GraphLayout.cs
new file mode 100644
index 0000000..29f38c4
@@ -0,0 +1,247 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Threading;
using Avalonia;
using Microsoft.CodeAnalysis;
namespace Cfa.Ui.Graph;
public static class GraphLayout
{
private const double SiblingGap = 32;
private static readonly SymbolDisplayFormat LabelFormat = new(
typeQualificationStyle: SymbolDisplayTypeQualificationStyle.NameOnly,
genericsOptions: SymbolDisplayGenericsOptions.IncludeTypeParameters,
memberOptions: SymbolDisplayMemberOptions.IncludeParameters,
parameterOptions: SymbolDisplayParameterOptions.IncludeType
| SymbolDisplayParameterOptions.IncludeName
| SymbolDisplayParameterOptions.IncludeParamsRefOut,
miscellaneousOptions: SymbolDisplayMiscellaneousOptions.UseSpecialTypes
| SymbolDisplayMiscellaneousOptions.EscapeKeywordIdentifiers
| SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifier
);
public static GraphScene Build(CallGraphAnalysis analysis, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(analysis);
cancellationToken.ThrowIfCancellationRequested();
var nodes = new List<GraphNode>();
var vertices = new List<Vertex>();
var links = new List<Link>();
var loopbacks = new HashSet<int>();
var origin = CreateNode(analysis.Origin, new Size(280, 80), true, false);
origin.Location = new Point(-origin.Width / 2, -origin.Height / 2);
var incoming = Expand(analysis.CalledFrom, -1);
var outgoing = Expand(analysis.CallsTo, 1);
for (var i = vertices.Count - 1; i >= 0; i--)
{
cancellationToken.ThrowIfCancellationRequested();
var vertex = vertices[i];
vertex.SubtreeHeight = Math.Max(vertex.Node.Height, ChildrenHeight(vertex.Children));
}
Position(incoming, -1);
Position(outgoing, 1);
var edges = new List<GraphEdge>(links.Count);
foreach (var link in links)
{
cancellationToken.ThrowIfCancellationRequested();
var source = link.Direction < 0 ? link.Child : link.Parent;
var target = link.Direction < 0 ? link.Parent : link.Child;
var loopback = ReferenceEquals(source, target);
var start = loopback
? new Point(source.Bounds.Right, source.Bounds.Center.Y - source.Height / 4)
: new Point(source.Bounds.Right, source.Bounds.Center.Y);
var end = loopback
? new Point(target.Bounds.Right, target.Bounds.Center.Y + target.Height / 4)
: new Point(target.Bounds.Left, target.Bounds.Center.Y);
edges.Add(new GraphEdge(source, target, start, end, CalculateThickness(link.Call), loopback));
}
return new GraphScene(nodes.AsReadOnly(), edges.AsReadOnly());
GraphNode CreateNode(ISymbol symbol, Size size, bool isOrigin, bool isCycleReference)
{
var syntax = symbol.DeclaringSyntaxReferences.Length > 0 ? symbol.DeclaringSyntaxReferences[0] : null;
string? sourceLink = null;
if (syntax is not null)
{
var start = syntax.SyntaxTree.GetLineSpan(syntax.Span, cancellationToken).StartLinePosition;
sourceLink = $"file://{syntax.SyntaxTree.FilePath}#{start.Line + 1}:{start.Character + 1}";
}
var node = new GraphNode(
nodes.Count,
symbol,
symbol.ToDisplayString(LabelFormat),
sourceLink,
size,
isOrigin,
isCycleReference
);
nodes.Add(node);
return node;
}
List<Vertex> Expand(IImmutableDictionary<ISymbol, IImmutableList<CallAnalysis>> map, int direction)
{
var roots = new List<Vertex>();
var path = new HashSet<ISymbol>(SymbolEqualityComparer.Default) { analysis.Origin };
var pending = new Stack<Frame>();
pending.Push(new Frame(origin, roots, GetCalls(analysis.Origin)));
while (pending.TryPeek(out var frame))
{
cancellationToken.ThrowIfCancellationRequested();
if (frame.Index == frame.Calls.Count)
{
pending.Pop();
path.Remove(frame.Node.Symbol);
continue;
}
var call = frame.Calls[frame.Index++];
if (SymbolEqualityComparer.Default.Equals(frame.Node.Symbol, call.Target))
{
if (loopbacks.Add(frame.Node.Id))
links.Add(new Link(frame.Node, frame.Node, call, direction));
continue;
}
var cycle = path.Contains(call.Target);
var appearances = analysis.Appearances.TryGetValue(call.Target, out var count) ? count : 1;
var node = CreateNode(call.Target, CalculateNodeSize(call, appearances), false, cycle);
var vertex = new Vertex(node, call);
vertices.Add(vertex);
frame.Children.Add(vertex);
links.Add(new Link(frame.Node, node, call, direction));
if (!cycle)
{
path.Add(call.Target);
pending.Push(new Frame(node, vertex.Children, GetCalls(call.Target)));
}
}
return roots;
IImmutableList<CallAnalysis> GetCalls(ISymbol symbol) =>
map.TryGetValue(symbol, out var calls) ? calls : ImmutableList<CallAnalysis>.Empty;
}
void Position(List<Vertex> roots, int direction)
{
var pending = new Queue<(GraphNode Parent, List<Vertex> Children)>();
pending.Enqueue((origin, roots));
while (pending.TryDequeue(out var group))
{
cancellationToken.ThrowIfCancellationRequested();
var top = group.Parent.Bounds.Center.Y - ChildrenHeight(group.Children) / 2;
foreach (var child in group.Children)
{
cancellationToken.ThrowIfCancellationRequested();
var centerY = top + child.SubtreeHeight / 2;
var offsetY = centerY - group.Parent.Bounds.Center.Y;
var length = CalculateEdgeLength(child.Call);
// Compact helper branches follow a semicircle; broad subtrees get extra outward space.
var projectedY = Math.Min(Math.Abs(offsetY), length * 0.85);
var gap = Math.Sqrt(length * length - projectedY * projectedY);
gap += Math.Min(160, Math.Max(0, child.SubtreeHeight - child.Node.Height) * 0.08);
var x =
direction > 0
? group.Parent.Bounds.Right + gap
: group.Parent.Bounds.Left - gap - child.Node.Width;
child.Node.Location = new Point(x, centerY - child.Node.Height / 2);
top += child.SubtreeHeight + SiblingGap;
pending.Enqueue((child.Node, child.Children));
}
}
}
double ChildrenHeight(List<Vertex> children)
{
var height = 0d;
foreach (var child in children)
{
cancellationToken.ThrowIfCancellationRequested();
height += child.SubtreeHeight;
}
return height + Math.Max(0, children.Count - 1) * SiblingGap;
}
}
/// <summary>Preferred anchor distance before space for broad branches is added.</summary>
public static double CalculateEdgeLength(CallAnalysis call) =>
90
+ (
call.LinkKind switch
{
LinkKind.Abstract => 0,
LinkKind.Direct => 24,
LinkKind.Event => 48,
_ => throw new ArgumentOutOfRangeException(nameof(call)),
}
)
+ (
call.Reach switch
{
CallReach.SameType => 0,
CallReach.SameNamespace => 36,
CallReach.SameAssembly => 72,
CallReach.CrossAssembly => 108,
_ => throw new ArgumentOutOfRangeException(nameof(call)),
}
);
/// <summary>Events and wider reach add prominence; repeats shrink within readable bounds. Origin size is independent.</summary>
public static Size CalculateNodeSize(CallAnalysis call, uint appearances)
{
var labelWidth = Math.Clamp(call.Target.ToDisplayString(LabelFormat).Length * 7.5 + 36, 180, 360);
var prominence =
1
+ (call.LinkKind == LinkKind.Event ? 0.3 : 0)
+ (
call.Reach switch
{
CallReach.SameType => 0,
CallReach.SameNamespace => 0.06,
CallReach.SameAssembly => 0.12,
CallReach.CrossAssembly => 0.2,
_ => throw new ArgumentOutOfRangeException(nameof(call)),
}
);
var scale = 1 / (1 + Math.Log2(Math.Max(1u, appearances)) * 0.12);
return new Size(
Math.Clamp(labelWidth * prominence * scale, 132, 480),
Math.Clamp(64 * prominence * scale, 44, 104)
);
}
public static double CalculateThickness(CallAnalysis call) =>
call.LinkKind switch
{
LinkKind.Abstract => 1.5,
LinkKind.Direct => 2.5,
LinkKind.Event => 3.5,
_ => throw new ArgumentOutOfRangeException(nameof(call)),
};
private sealed class Vertex(GraphNode node, CallAnalysis call)
{
public GraphNode Node { get; } = node;
public CallAnalysis Call { get; } = call;
public List<Vertex> Children { get; } = [];
public double SubtreeHeight { get; set; }
}
private sealed class Frame(GraphNode node, List<Vertex> children, IImmutableList<CallAnalysis> calls)
{
public GraphNode Node { get; } = node;
public List<Vertex> Children { get; } = children;
public IImmutableList<CallAnalysis> Calls { get; } = calls;
public int Index { get; set; }
}
private sealed record Link(GraphNode Parent, GraphNode Child, CallAnalysis Call, int Direction);
}
ui/src/Graph/GraphScene.cs +91 -0
diff --git a/ui/src/Graph/GraphScene.cs b/ui/src/Graph/GraphScene.cs
new file mode 100644
index 0000000..a86814a
@@ -0,0 +1,91 @@
using System;
using System.Collections.Generic;
using Avalonia;
using Microsoft.CodeAnalysis;
namespace Cfa.Ui.Graph;
public sealed record GraphScene(IReadOnlyList<GraphNode> Nodes, IReadOnlyList<GraphEdge> Edges)
{
/// <summary>Conservative viewport bounds including connection curves, strokes, and arrowheads.</summary>
public Rect Bounds { get; } = CalculateBounds(Nodes, Edges);
private static Rect CalculateBounds(IReadOnlyList<GraphNode> nodes, IReadOnlyList<GraphEdge> edges)
{
Rect? bounds = null;
foreach (var node in nodes)
bounds = bounds?.Union(node.Bounds) ?? node.Bounds;
foreach (var edge in edges)
{
// A cubic curve lies within its control-point hull; 15 also covers the 13-by-6 arrowhead.
var curve = Envelope(edge.Start, edge.End)
.Union(Envelope(edge.Control1, edge.Control2))
.Inflate(15 + edge.Thickness / 2);
bounds = bounds?.Union(curve) ?? curve;
}
return bounds ?? default;
static Rect Envelope(Point first, Point second) =>
new(
Math.Min(first.X, second.X),
Math.Min(first.Y, second.Y),
Math.Abs(first.X - second.X),
Math.Abs(first.Y - second.Y)
);
}
}
public sealed class GraphNode
{
internal GraphNode(
int id,
ISymbol symbol,
string label,
string? sourceLink,
Size size,
bool isOrigin,
bool isCycleReference
)
{
Id = id;
Symbol = symbol;
Label = label;
SourceLink = sourceLink;
Width = size.Width;
Height = size.Height;
IsOrigin = isOrigin;
IsCycleReference = isCycleReference;
}
public int Id { get; }
public ISymbol Symbol { get; }
public string Label { get; }
public string? SourceLink { get; }
public Point Location { get; internal set; }
public double Width { get; }
public double Height { get; }
public bool IsOrigin { get; }
public bool IsCycleReference { get; }
public Rect Bounds => new(Location, new Size(Width, Height));
}
public sealed record GraphEdge(
GraphNode Source,
GraphNode Target,
Point Start,
Point End,
double Thickness,
bool IsLoopback
)
{
/// <summary>Cubic control points shared by connection rendering and viewport fitting.</summary>
public Point Control1 =>
IsLoopback
? new Point(Start.X + 100, Start.Y - 100)
: new Point(Start.X + Math.Max(40, Math.Abs(End.X - Start.X) * 0.45), Start.Y);
public Point Control2 =>
IsLoopback
? new Point(End.X - 100, End.Y - 100)
: new Point(End.X - Math.Max(40, Math.Abs(End.X - Start.X) * 0.45), End.Y);
}
ui/src/Program.cs +13 -0
diff --git a/ui/src/Program.cs b/ui/src/Program.cs
new file mode 100644
index 0000000..5e80b8b
@@ -0,0 +1,13 @@
using System;
using Avalonia;
namespace Cfa.Ui;
internal static class Program
{
[STAThread]
public static void Main(string[] args) => BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
public static AppBuilder BuildAvaloniaApp() =>
AppBuilder.Configure<App>().UsePlatformDetect().WithInterFont().LogToTrace();
}
ui/src/Services/DesktopServices.cs +69 -0
diff --git a/ui/src/Services/DesktopServices.cs b/ui/src/Services/DesktopServices.cs
new file mode 100644
index 0000000..f9ccdbf
@@ -0,0 +1,69 @@
using System;
using System.IO;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Layout;
using Avalonia.Platform.Storage;
namespace Cfa.Ui.Services;
public interface IDesktopServices
{
Task<FileInfo?> PickSolutionAsync();
Task ShowErrorAsync(string message);
Task OpenSourceAsync(string link);
}
public sealed class DesktopServices(Window owner) : IDesktopServices
{
public async Task<FileInfo?> PickSolutionAsync()
{
var files = await owner.StorageProvider.OpenFilePickerAsync(
new FilePickerOpenOptions
{
Title = "Open Solution",
AllowMultiple = false,
FileTypeFilter = [new FilePickerFileType("C# solutions") { Patterns = ["*.sln", "*.slnx", "*.slnf"] }],
}
);
return files.Count > 0 && files[0].TryGetLocalPath() is { } path ? new FileInfo(path) : null;
}
public async Task OpenSourceAsync(string link)
{
if (!await owner.Launcher.LaunchUriAsync(new Uri(link)))
{
throw new InvalidOperationException("The operating system could not open this source link: " + link);
}
}
public Task ShowErrorAsync(string message)
{
var close = new Button { Content = "Close", HorizontalAlignment = HorizontalAlignment.Right };
var dialog = new Window
{
Title = "Code Flow Analysis",
Width = 560,
Height = 260,
CanResize = true,
WindowStartupLocation = WindowStartupLocation.CenterOwner,
Content = new Grid
{
Margin = new Thickness(24),
RowDefinitions = new RowDefinitions("*,Auto"),
Children =
{
new ScrollViewer
{
Content = new TextBlock { Text = message, TextWrapping = Avalonia.Media.TextWrapping.Wrap },
},
close,
},
},
};
Grid.SetRow(close, 1);
close.Click += (_, _) => dialog.Close();
return dialog.ShowDialog(owner);
}
}
ui/src/Services/ScanSession.cs +37 -0
diff --git a/ui/src/Services/ScanSession.cs b/ui/src/Services/ScanSession.cs
new file mode 100644
index 0000000..41ffcaa
@@ -0,0 +1,37 @@
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
namespace Cfa.Ui.Services;
public interface IScanSession : IDisposable
{
Task<CallGraphAnalysis> AnalyzeAsync(FileInfo file, int line, CancellationToken token);
}
public interface IScanSessionFactory
{
Task<IScanSession> OpenAsync(FileInfo solution, CancellationToken token);
}
public sealed class ScanSessionFactory : IScanSessionFactory
{
public async Task<IScanSession> OpenAsync(FileInfo solution, CancellationToken token)
{
var options = new ScannerOptions
{
Messages = MessageOptions.LoadJson(Path.Combine(solution.DirectoryName!, ".cfa.json"), optional: true),
};
var scanner = await Scanner.Create(solution, options, token);
return new ScanSession(scanner);
}
private sealed class ScanSession(Scanner scanner) : IScanSession
{
public async Task<CallGraphAnalysis> AnalyzeAsync(FileInfo file, int line, CancellationToken token) =>
Analyzer.Analyze(await scanner.BuildCallGraph(file, line, token));
public void Dispose() => scanner.Dispose();
}
}
ui/src/ViewModels/MainViewModel.cs +192 -0
diff --git a/ui/src/ViewModels/MainViewModel.cs b/ui/src/ViewModels/MainViewModel.cs
new file mode 100644
index 0000000..72a576e
@@ -0,0 +1,192 @@
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Cfa.Ui.Graph;
using Cfa.Ui.Services;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace Cfa.Ui.ViewModels;
public sealed class MainViewModel : ObservableObject, IDisposable
{
private readonly IDesktopServices desktop;
private readonly IScanSessionFactory sessions;
private readonly CancellationTokenSource lifetime = new();
private IScanSession? scanner;
private FileInfo? solution;
private bool isBusy;
private bool disposed;
private string address = "";
private string status = "Open a solution to explore its call graph.";
private GraphScene? scene;
public MainViewModel(IDesktopServices desktop, IScanSessionFactory sessions)
{
this.desktop = desktop;
this.sessions = sessions;
OpenSolutionCommand = new AsyncRelayCommand(OpenSolutionAsync, () => !IsBusy);
AnalyzeCommand = new AsyncRelayCommand(AnalyzeAsync, () => CanAnalyze);
OpenSourceCommand = new AsyncRelayCommand<GraphNode>(OpenSourceAsync);
}
public IAsyncRelayCommand OpenSolutionCommand { get; }
public IAsyncRelayCommand AnalyzeCommand { get; }
public IAsyncRelayCommand<GraphNode> OpenSourceCommand { get; }
public bool CanAnalyze => scanner is not null && !IsBusy;
public bool HasScene => Scene is not null;
public string Address
{
get => address;
set => SetProperty(ref address, value);
}
public string Status
{
get => status;
private set => SetProperty(ref status, value);
}
public bool IsBusy
{
get => isBusy;
private set
{
if (SetProperty(ref isBusy, value))
{
OnPropertyChanged(nameof(CanAnalyze));
OpenSolutionCommand.NotifyCanExecuteChanged();
AnalyzeCommand.NotifyCanExecuteChanged();
}
}
}
public GraphScene? Scene
{
get => scene;
private set
{
if (SetProperty(ref scene, value))
{
OnPropertyChanged(nameof(HasScene));
}
}
}
private async Task OpenSolutionAsync()
{
IsBusy = true;
try
{
var file = await desktop.PickSolutionAsync();
if (file is null)
{
return;
}
Status = "Loading " + file.Name + "...";
var loaded = await Task.Run(() => sessions.OpenAsync(file, lifetime.Token), lifetime.Token);
if (disposed)
{
loaded.Dispose();
return;
}
scanner?.Dispose();
scanner = loaded;
solution = file;
Scene = null;
Status = file.Name + " loaded. Enter a source path:line.";
}
catch (OperationCanceledException) when (lifetime.IsCancellationRequested) { }
catch (Exception exception)
{
Status = "Solution loading failed.";
await desktop.ShowErrorAsync(exception.Message);
}
finally
{
FinishOperation();
}
}
private async Task AnalyzeAsync()
{
IsBusy = true;
try
{
var separator = Address.LastIndexOf(':');
if (separator <= 0 || !int.TryParse(Address[(separator + 1)..], out var line) || line < 1)
{
throw new ArgumentException("Enter a source file and line, for example core/src/Scanner.cs:50.");
}
var path = Address[..separator].Trim();
var file = new FileInfo(Path.GetFullPath(path, solution!.DirectoryName!));
Status = "Analyzing call graph...";
var current = scanner!;
var result = await Task.Run(
async () =>
{
var analysis = await current.AnalyzeAsync(file, line, lifetime.Token);
return GraphLayout.Build(analysis, lifetime.Token);
},
lifetime.Token
);
if (!disposed)
{
Scene = result;
Status =
$"{result.Nodes.Count} occurrences, {result.Edges.Count} calls. Right/middle-drag to pan; scroll to zoom.";
}
}
catch (OperationCanceledException) when (lifetime.IsCancellationRequested) { }
catch (Exception exception)
{
Status = "Analysis failed.";
await desktop.ShowErrorAsync(exception.Message);
}
finally
{
FinishOperation();
}
}
private async Task OpenSourceAsync(GraphNode? node)
{
if (node?.SourceLink is not { } link)
{
return;
}
try
{
await desktop.OpenSourceAsync(link);
}
catch (Exception exception)
{
await desktop.ShowErrorAsync(exception.Message);
}
}
public void Dispose()
{
if (disposed)
{
return;
}
disposed = true;
lifetime.Cancel();
if (!IsBusy)
{
scanner?.Dispose();
scanner = null;
lifetime.Dispose();
}
}
private void FinishOperation()
{
IsBusy = false;
if (disposed)
{
scanner?.Dispose();
scanner = null;
lifetime.Dispose();
}
}
}
ui/src/Views/GraphConnection.cs +59 -0
diff --git a/ui/src/Views/GraphConnection.cs b/ui/src/Views/GraphConnection.cs
new file mode 100644
index 0000000..6b2ecfc
@@ -0,0 +1,59 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Media;
using Cfa.Ui.Graph;
namespace Cfa.Ui.Views;
public sealed class GraphConnection : Control
{
public static readonly StyledProperty<GraphEdge?> EdgeProperty = AvaloniaProperty.Register<
GraphConnection,
GraphEdge?
>(nameof(Edge));
static GraphConnection() => AffectsRender<GraphConnection>(EdgeProperty);
public GraphEdge? Edge
{
get => GetValue(EdgeProperty);
set => SetValue(EdgeProperty, value);
}
public override void Render(DrawingContext context)
{
base.Render(context);
if (Edge is not { } edge)
{
return;
}
var start = edge.Start;
var end = edge.End;
var control1 = edge.Control1;
var control2 = edge.Control2;
var geometry = new StreamGeometry();
using (var drawing = geometry.Open())
{
drawing.BeginFigure(start, false);
drawing.CubicBezierTo(control1, control2, end);
drawing.EndFigure(false);
}
var brush = Brushes.SkyBlue;
context.DrawGeometry(null, new Pen(brush, edge.Thickness), geometry);
var direction = new Vector(end.X - control2.X, end.Y - control2.Y);
if (direction.Length > 0)
{
direction /= direction.Length;
var normal = new Vector(-direction.Y, direction.X);
var arrow = new StreamGeometry();
using (var drawing = arrow.Open())
{
drawing.BeginFigure(end, true);
drawing.LineTo(end - direction * 13 + normal * 6);
drawing.LineTo(end - direction * 13 - normal * 6);
drawing.EndFigure(true);
}
context.DrawGeometry(brush, null, arrow);
}
}
}
ui/src/Views/MainWindow.axaml +158 -0
diff --git a/ui/src/Views/MainWindow.axaml b/ui/src/Views/MainWindow.axaml
new file mode 100644
index 0000000..97eb792
@@ -0,0 +1,158 @@
<Window
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:nodify="https://miroiu.github.io/nodify"
xmlns:vm="using:Cfa.Ui.ViewModels"
xmlns:graph="using:Cfa.Ui.Graph"
xmlns:views="using:Cfa.Ui.Views"
x:Class="Cfa.Ui.Views.MainWindow"
x:DataType="vm:MainViewModel"
Title="Code Flow Analysis"
Width="1400"
Height="900"
MinWidth="800"
MinHeight="500"
>
<Grid RowDefinitions="Auto,Auto,*,Auto">
<Menu>
<MenuItem Header="_File">
<MenuItem
Header="_Open Solution..."
Name="OpenSolutionMenu"
Command="{Binding OpenSolutionCommand}"
InputGesture="Ctrl+O"
/>
</MenuItem>
</Menu>
<Grid Grid.Row="1" ColumnDefinitions="*,Auto,Auto" Margin="16,8,16,12">
<TextBox
Name="AddressBar"
Text="{Binding Address}"
IsEnabled="{Binding CanAnalyze}"
Watermark="Source file:line (relative to solution)"
VerticalContentAlignment="Center"
>
<TextBox.KeyBindings>
<KeyBinding Gesture="Enter" Command="{Binding AnalyzeCommand}" />
</TextBox.KeyBindings>
</TextBox>
<Button
Grid.Column="1"
Name="AnalyzeButton"
Margin="8,0,0,0"
Content="Explore"
Command="{Binding AnalyzeCommand}"
/>
<Button
Grid.Column="2"
Margin="8,0,0,0"
Content="Fit graph"
Click="FitGraph"
IsEnabled="{Binding HasScene}"
/>
</Grid>
<Grid Grid.Row="2" ClipToBounds="True">
<nodify:NodifyEditor
Name="Editor"
ItemsSource="{Binding Scene.Nodes}"
Connections="{Binding Scene.Edges}"
CanSelectMultipleItems="False"
CanSelectMultipleConnections="False"
DisableAutoPanning="True"
>
<nodify:NodifyEditor.Background>
<VisualBrush
TileMode="Tile"
DestinationRect="0,0,32,32"
SourceRect="0,0,32,32"
Transform="{Binding ViewportTransform, ElementName=Editor}"
>
<VisualBrush.Visual>
<Canvas Width="32" Height="32" Background="#111827">
<Ellipse Width="2" Height="2" Fill="#334155" Canvas.Left="15" Canvas.Top="15" />
</Canvas>
</VisualBrush.Visual>
</VisualBrush>
</nodify:NodifyEditor.Background>
<nodify:NodifyEditor.Styles>
<Style Selector="nodify|ItemContainer" x:DataType="graph:GraphNode">
<Setter Property="Location" Value="{Binding Location, Mode=OneWay}" />
<Setter Property="IsDraggable" Value="False" />
<Setter Property="IsSelectable" Value="False" />
<Setter Property="Focusable" Value="False" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="BorderBrush" Value="Transparent" />
</Style>
</nodify:NodifyEditor.Styles>
<nodify:NodifyEditor.ItemTemplate>
<DataTemplate x:DataType="graph:GraphNode">
<Button
Classes="member"
Classes.origin="{Binding IsOrigin}"
Classes.cycle="{Binding IsCycleReference}"
Width="{Binding Width}"
Height="{Binding Height}"
Content="{Binding Label}"
CommandParameter="{Binding}"
Command="{Binding $parent[Window].((vm:MainViewModel)DataContext).OpenSourceCommand}"
ToolTip.Tip="{Binding SourceLink}"
AutomationProperties.Name="{Binding Label}"
>
<Button.Styles>
<Style Selector="Button.member">
<Setter Property="Background" Value="#1E293B" />
<Setter Property="BorderBrush" Value="#38BDF8" />
<Setter Property="BorderThickness" Value="2" />
<Setter Property="CornerRadius" Value="18" />
<Setter Property="Padding" Value="12,8" />
<Setter Property="HorizontalContentAlignment" Value="Center" />
<Setter Property="VerticalContentAlignment" Value="Center" />
</Style>
<Style Selector="Button.member.origin">
<Setter Property="Background" Value="#164E63" />
<Setter Property="BorderBrush" Value="#67E8F9" />
</Style>
<Style Selector="Button.member.cycle">
<Setter Property="BorderBrush" Value="#FBBF24" />
</Style>
</Button.Styles>
<Button.ContentTemplate>
<DataTemplate>
<TextBlock
Text="{Binding}"
TextWrapping="Wrap"
TextAlignment="Center"
FontSize="13"
/>
</DataTemplate>
</Button.ContentTemplate>
</Button>
</DataTemplate>
</nodify:NodifyEditor.ItemTemplate>
<nodify:NodifyEditor.ConnectionTemplate>
<DataTemplate x:DataType="graph:GraphEdge">
<views:GraphConnection Edge="{Binding}" IsHitTestVisible="False" />
</DataTemplate>
</nodify:NodifyEditor.ConnectionTemplate>
</nodify:NodifyEditor>
<TextBlock
Text="Open a solution, then enter a source file:line to explore."
IsVisible="{Binding !HasScene}"
IsHitTestVisible="False"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Foreground="#94A3B8"
/>
<Border IsVisible="{Binding IsBusy}" Background="#B3111827">
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="12">
<ProgressBar Name="BusySpinner" IsIndeterminate="True" Width="220" Height="5" />
<TextBlock Text="{Binding Status}" HorizontalAlignment="Center" />
</StackPanel>
</Border>
</Grid>
<TextBlock Grid.Row="3" Margin="16,8" Text="{Binding Status}" Foreground="#94A3B8" />
</Grid>
<Window.KeyBindings>
<KeyBinding Gesture="Ctrl+O" Command="{Binding OpenSolutionCommand}" />
</Window.KeyBindings>
</Window>
ui/src/Views/MainWindow.axaml.cs +61 -0
diff --git a/ui/src/Views/MainWindow.axaml.cs b/ui/src/Views/MainWindow.axaml.cs
new file mode 100644
index 0000000..c91b84d
@@ -0,0 +1,61 @@
using System;
using System.ComponentModel;
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Markup.Xaml;
using Avalonia.Threading;
using Cfa.Ui.ViewModels;
using Nodify;
namespace Cfa.Ui.Views;
public sealed partial class MainWindow : Window
{
private MainViewModel? model;
public MainWindow()
{
AvaloniaXamlLoader.Load(this);
DataContextChanged += OnDataContextChanged;
Closed += (_, _) =>
{
if (model is not null)
{
model.PropertyChanged -= OnModelChanged;
}
};
}
private void OnDataContextChanged(object? sender, EventArgs args)
{
if (model is not null)
{
model.PropertyChanged -= OnModelChanged;
}
model = DataContext as MainViewModel;
if (model is not null)
{
model.PropertyChanged += OnModelChanged;
}
}
private void OnModelChanged(object? sender, PropertyChangedEventArgs args)
{
if (args.PropertyName == nameof(MainViewModel.Scene))
{
Dispatcher.UIThread.Post(() => FitGraph(this, new RoutedEventArgs()), DispatcherPriority.Loaded);
}
}
private void FitGraph(object? sender, RoutedEventArgs args)
{
var editor = this.FindControl<NodifyEditor>("Editor")!;
editor.UpdateLayout();
if (model?.Scene is { } scene)
{
var horizontal = Math.Max(Math.Abs(scene.Bounds.Left), Math.Abs(scene.Bounds.Right));
var vertical = Math.Max(Math.Abs(scene.Bounds.Top), Math.Abs(scene.Bounds.Bottom));
editor.FitToScreen(new Avalonia.Rect(-horizontal, -vertical, horizontal * 2, vertical * 2));
}
}
}
ui/src/packages.lock.json +415 -0
diff --git a/ui/src/packages.lock.json b/ui/src/packages.lock.json
new file mode 100644
index 0000000..89cca78
@@ -0,0 +1,415 @@
{
"version": 2,
"dependencies": {
"net11.0": {
"Avalonia": {
"type": "Direct",
"requested": "[11.3.22, )",
"resolved": "11.3.22",
"contentHash": "61kKbPq/2W/S8PkjRYnDCSvH2fAvhC3eTrYFtwMUZVShP5M2xXII76kIQo8bcezxwFkHc/yoRP3xzae2tFZRsQ==",
"dependencies": {
"Avalonia.BuildServices": "11.3.2",
"Avalonia.Remote.Protocol": "11.3.22",
"MicroCom.Runtime": "0.11.0"
}
},
"Avalonia.Desktop": {
"type": "Direct",
"requested": "[11.3.22, )",
"resolved": "11.3.22",
"contentHash": "TXdIJ5WA473KqFd/b3EqnXUIgFDhPuOx2OkzG7Fm8EkCPF5JKkCwg+i7yq8IRsYKcQ4I8X/4HkUyCb8DcfgfYg==",
"dependencies": {
"Avalonia": "11.3.22",
"Avalonia.Native": "11.3.22",
"Avalonia.Skia": "11.3.22",
"Avalonia.Win32": "11.3.22",
"Avalonia.X11": "11.3.22"
}
},
"Avalonia.Fonts.Inter": {
"type": "Direct",
"requested": "[11.3.22, )",
"resolved": "11.3.22",
"contentHash": "oGIn2U71EWTcTQ+zQU0CuJ5b+QsXIvmN7kvn5VSszgU6baZ0/1hDsR3wwXsU7hDFnJD1G541wtPqaGlZtKnPhA==",
"dependencies": {
"Avalonia": "11.3.22"
}
},
"Avalonia.Themes.Fluent": {
"type": "Direct",
"requested": "[11.3.22, )",
"resolved": "11.3.22",
"contentHash": "tCui436MQuxeIXj/eA/Mq9neAgNTud0Jk22HDWSo05KT+vaH5oJa53xI8PxEi3uRSsWK2l9yoQciLKrzzIrqCw==",
"dependencies": {
"Avalonia": "11.3.22"
}
},
"CommunityToolkit.Mvvm": {
"type": "Direct",
"requested": "[8.4.0, )",
"resolved": "8.4.0",
"contentHash": "tqVU8yc/ADO9oiTRyTnwhFN68hCwvkliMierptWOudIAvWY1mWCh5VFh+guwHJmpMwfg0J0rY+yyd5Oy7ty9Uw=="
},
"CSharpier.MsBuild": {
"type": "Direct",
"requested": "[1.3.0, )",
"resolved": "1.3.0",
"contentHash": "jzzLpmz7F6LyBh5FTZy8/4dEYVcAG9VjBPKryZ82LIoUuupcBkCSXzaeS/4oYcp/rBC8yVLcDstJ8CIv9n2IFw=="
},
"NodifyAvalonia": {
"type": "Direct",
"requested": "[6.6.0, )",
"resolved": "6.6.0",
"contentHash": "brSHSolQsce961EhMFelICg7rTriYzM2HOOZj+falLhgjatPShltnUzaQqYvKVgeTQ7w+vwDB2Z27DZR0FgDpw==",
"dependencies": {
"Avalonia": "11.1.0"
}
},
"Avalonia.Angle.Windows.Natives": {
"type": "Transitive",
"resolved": "2.1.25547.20250602",
"contentHash": "ZL0VLc4s9rvNNFt19Pxm5UNAkmKNylugAwJPX9ulXZ6JWs/l6XZihPWWTyezaoNOVyEPU8YbURtW7XMAtqXH5A=="
},
"Avalonia.BuildServices": {
"type": "Transitive",
"resolved": "11.3.2",
"contentHash": "qHDToxto1e3hci5YqbG9n0Ty8mlp3zBUN5wT66wKqaDVzXyQ0do3EnRILd4Ke9jpvsktaPpgE0YjEk7hornryQ=="
},
"Avalonia.FreeDesktop": {
"type": "Transitive",
"resolved": "11.3.22",
"contentHash": "heXRjopzKbNVGnLI+YPM6VBcf+cKGKKNdyXBYAlAlbcPfkExOTrGBSPDSSSHaT9uEByqZ4xo/9T0IqDtGm2PAQ==",
"dependencies": {
"Avalonia": "11.3.22",
"Tmds.DBus.Protocol": "0.21.3"
}
},
"Avalonia.Native": {
"type": "Transitive",
"resolved": "11.3.22",
"contentHash": "sU/KJQKqrRkstS1EWIhRvD/ADIkItoOSss3sL/wtPFFFJINPOrge5qMnwNi0RbDzNKb4TrZwEv6eeD27Ip+ACQ==",
"dependencies": {
"Avalonia": "11.3.22"
}
},
"Avalonia.Remote.Protocol": {
"type": "Transitive",
"resolved": "11.3.22",
"contentHash": "Ll5YswLkNW0WZm7RkvGS+8B9vH4/x77IsnCqlBvmXOgiQd0J5bpJQFYo1NDVyRLadp4e8Yqg1WP2xEc6IK94HQ=="
},
"Avalonia.Skia": {
"type": "Transitive",
"resolved": "11.3.22",
"contentHash": "ZbnfFCr2f1l3RD1E/Tn5tJDRFIDm9ICY/PzG4lhuShd6mDg+0y5wRCytCYOSM79KhlboU/f0O7J1Mw7s83Sy3A==",
"dependencies": {
"Avalonia": "11.3.22",
"HarfBuzzSharp": "8.3.1.1",
"HarfBuzzSharp.NativeAssets.Linux": "8.3.1.1",
"HarfBuzzSharp.NativeAssets.WebAssembly": "8.3.1.1",
"SkiaSharp": "2.88.9",
"SkiaSharp.NativeAssets.Linux": "2.88.9",
"SkiaSharp.NativeAssets.WebAssembly": "2.88.9"
}
},
"Avalonia.Win32": {
"type": "Transitive",
"resolved": "11.3.22",
"contentHash": "9kP1kWFJtbW2fFmGY2fs1kn0Rx1ARpf3Mz+QrLhagYUp6RaMN5Imus5YiOQWCTe8ZkQqktt8PsKUDWyvj8SiYQ==",
"dependencies": {
"Avalonia": "11.3.22",
"Avalonia.Angle.Windows.Natives": "2.1.25547.20250602"
}
},
"Avalonia.X11": {
"type": "Transitive",
"resolved": "11.3.22",
"contentHash": "w4OZxdAPAjGDKpBrU5kbiXzbbQT/ZcdGTMareDODdyIPFvd4AX+5iPAv08Ih87evHOXAf04dpvJyAEPgw1kMFg==",
"dependencies": {
"Avalonia": "11.3.22",
"Avalonia.FreeDesktop": "11.3.22",
"Avalonia.Skia": "11.3.22"
}
},
"HarfBuzzSharp": {
"type": "Transitive",
"resolved": "8.3.1.1",
"contentHash": "tLZN66oe/uiRPTZfrCU4i8ScVGwqHNh5MHrXj0yVf4l7Mz0FhTGnQ71RGySROTmdognAs0JtluHkL41pIabWuQ==",
"dependencies": {
"HarfBuzzSharp.NativeAssets.Win32": "8.3.1.1",
"HarfBuzzSharp.NativeAssets.macOS": "8.3.1.1"
}
},
"HarfBuzzSharp.NativeAssets.Linux": {
"type": "Transitive",
"resolved": "8.3.1.1",
"contentHash": "3EZ1mpIiKWRLL5hUYA82ZHteeDIVaEA/Z0rA/wU6tjx6crcAkJnBPwDXZugBSfo8+J3EznvRJf49uMsqYfKrHg=="
},
"HarfBuzzSharp.NativeAssets.macOS": {
"type": "Transitive",
"resolved": "8.3.1.1",
"contentHash": "jbtCsgftcaFLCA13tVKo5iWdElJScrulLTKJre36O4YQTIlwDtPPqhRZNk+Y0vv4D1gxbscasGRucUDfS44ofQ=="
},
"HarfBuzzSharp.NativeAssets.WebAssembly": {
"type": "Transitive",
"resolved": "8.3.1.1",
"contentHash": "loJweK2u/mH/3C2zBa0ggJlITIszOkK64HLAZB7FUT670dTg965whLFYHDQo69NmC4+d9UN0icLC9VHidXaVCA=="
},
"HarfBuzzSharp.NativeAssets.Win32": {
"type": "Transitive",
"resolved": "8.3.1.1",
"contentHash": "UsJtQsfAJoFDZrXc4hCUfRPMqccfKZ0iumJ/upcUjz/cmsTgVFGNEL5yaJWmkqsuFYdMWbj/En5/kS4PFl9hBA=="
},
"Humanizer.Core": {
"type": "Transitive",
"resolved": "2.14.1",
"contentHash": "lQKvtaTDOXnoVJ20ibTuSIOf2i0uO0MPbDhd1jm238I+U/2ZnRENj0cktKZhtchBMtCUSRQ5v4xBCUbKNmyVMw=="
},
"MicroCom.Runtime": {
"type": "Transitive",
"resolved": "0.11.0",
"contentHash": "MEnrZ3UIiH40hjzMDsxrTyi8dtqB5ziv3iBeeU4bXsL/7NLSal9F1lZKpK+tfBRnUoDSdtcW3KufE4yhATOMCA=="
},
"Microsoft.Build.Framework": {
"type": "Transitive",
"resolved": "17.11.48",
"contentHash": "C3WIMt2wBl4++NX3jSEpTq5KXBhvAV154R4JrYHkfy9JSBcXWiL0mkgpspk5xSdOj+fS/uz7zluIy6bMM1fkkQ=="
},
"Microsoft.CodeAnalysis.Analyzers": {
"type": "Transitive",
"resolved": "5.9.0-1.26328.17",
"contentHash": "HP9NNk8ZjOSI2hgOyXnQg+kv7/X837Vr2nAlXiGAtqtYnYKjRRa1UmQFr8KFs5ynGYKqfbb8zB9APoWjiAGdMg=="
},
"Microsoft.CodeAnalysis.Common": {
"type": "Transitive",
"resolved": "5.9.0",
"contentHash": "IYaIaUWdIx539AReKZOBEqTskFusZfCh/wFSPilDvCn5Say8MegLw2LONcSIcVy+v3Gzv53qYBspgvBGSErfbQ==",
"dependencies": {
"Microsoft.CodeAnalysis.Analyzers": "5.9.0-1.26328.17"
}
},
"Microsoft.CodeAnalysis.CSharp": {
"type": "Transitive",
"resolved": "5.9.0",
"contentHash": "7JGDA0UT1+h7k9ZcA3rF4eFC8+QPq1xyYaXxag4p8r/zzPurEJxvdi7aM+MRL/SfP7XADXpWF/pl/eUYXOq/ww==",
"dependencies": {
"Microsoft.CodeAnalysis.Analyzers": "5.9.0-1.26328.17",
"Microsoft.CodeAnalysis.Common": "[5.9.0]"
}
},
"Microsoft.CodeAnalysis.Workspaces.Common": {
"type": "Transitive",
"resolved": "5.9.0",
"contentHash": "1A6jz50NG4nOEW8tX5+h+MyHqjWL0mPGwrUdwu+OlTfyknLo0GfxSqj4zEks8uVUdHdo9v8Ir9dHxACf8iYNEA==",
"dependencies": {
"Humanizer.Core": "2.14.1",
"Microsoft.CodeAnalysis.Analyzers": "5.9.0-1.26328.17",
"Microsoft.CodeAnalysis.Common": "[5.9.0]",
"System.Composition": "10.0.1"
}
},
"Microsoft.Extensions.Configuration": {
"type": "Transitive",
"resolved": "11.0.0-rc.3.26522.148",
"contentHash": "eKYZzPkX3fQy+NBmU6Jn6i4K43cF7VR0EvEbe2xd+92LSjuW9KeLOG4IK8c1z6eZ0QhAp7t1m94EYrbFtCn6pQ=="
},
"Microsoft.Extensions.Configuration.Binder": {
"type": "Transitive",
"resolved": "11.0.0-rc.3.26522.148",
"contentHash": "/vkvZf2C7HOlKcxV7LzTnJ0S7kShfZbt+9wDvJpuqEV83ijZOSUf/npwEDFsjpw97EpX/0p0wxpjTUhgJJpk7w==",
"dependencies": {
"Microsoft.Extensions.Configuration": "11.0.0-rc.3.26522.148"
}
},
"Microsoft.Extensions.Configuration.FileExtensions": {
"type": "Transitive",
"resolved": "11.0.0-rc.3.26522.148",
"contentHash": "6YVPdNAEGVHh3LIapJ0WRtTmZjrDOaEcWpcX/Tr9Wd3p8rHJfqJ1uTKEwKpYf7NAgN24Ovh7RlX07ygdfz9TEw==",
"dependencies": {
"Microsoft.Extensions.Configuration": "11.0.0-rc.3.26522.148",
"Microsoft.Extensions.FileProviders.Physical": "11.0.0-rc.3.26522.148"
}
},
"Microsoft.Extensions.DependencyInjection": {
"type": "Transitive",
"resolved": "10.0.1",
"contentHash": "zerXV0GAR9LCSXoSIApbWn+Dq1/T+6vbXMHGduq1LoVQRHT0BXsGQEau0jeLUBUcsoF/NaUT8ADPu8b+eNcIyg=="
},
"Microsoft.Extensions.FileProviders.Physical": {
"type": "Transitive",
"resolved": "11.0.0-rc.3.26522.148",
"contentHash": "J0HtYwiJ7NFE3WMxKiJnixzYWPZmDGpbq+r6lWzzAF8MxznwW8DgCla6f1Sa76NkRuTFLhelOSA5Y4GIdR0xFQ==",
"dependencies": {
"Microsoft.Extensions.FileSystemGlobbing": "11.0.0-rc.3.26522.148"
}
},
"Microsoft.Extensions.FileSystemGlobbing": {
"type": "Transitive",
"resolved": "11.0.0-rc.3.26522.148",
"contentHash": "orWLCLpdv+XAyUA7zrrhAUZEHjoUkg4DeyxJIEs9xbg/FnuYhbRpL6msSdH+5WEvrw0TKjP4U0AYIlZx2WqXig=="
},
"Microsoft.Extensions.Logging": {
"type": "Transitive",
"resolved": "10.0.1",
"contentHash": "9ItMpMLFZFJFqCuHLLbR3LiA4ahA8dMtYuXpXl2YamSDWZhYS9BruPprkftY0tYi2bQ0slNrixdFm+4kpz1g5w==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection": "10.0.1"
}
},
"Microsoft.VisualStudio.SolutionPersistence": {
"type": "Transitive",
"resolved": "1.0.52",
"contentHash": "oNv2JtYXhpdJrX63nibx1JT3uCESOBQ1LAk7Dtz/sr0+laW0KRM6eKp4CZ3MHDR2siIkKsY8MmUkeP5DKkQQ5w=="
},
"SkiaSharp": {
"type": "Transitive",
"resolved": "2.88.9",
"contentHash": "3MD5VHjXXieSHCleRLuaTXmL2pD0mB7CcOB1x2kA1I4bhptf4e3R27iM93264ZYuAq6mkUyX5XbcxnZvMJYc1Q==",
"dependencies": {
"SkiaSharp.NativeAssets.Win32": "2.88.9",
"SkiaSharp.NativeAssets.macOS": "2.88.9"
}
},
"SkiaSharp.NativeAssets.Linux": {
"type": "Transitive",
"resolved": "2.88.9",
"contentHash": "cWSaJKVPWAaT/WIn9c8T5uT/l4ETwHxNJTkEOtNKjphNo8AW6TF9O32aRkxqw3l8GUdUo66Bu7EiqtFh/XG0Zg==",
"dependencies": {
"SkiaSharp": "2.88.9"
}
},
"SkiaSharp.NativeAssets.macOS": {
"type": "Transitive",
"resolved": "2.88.9",
"contentHash": "Nv5spmKc4505Ep7oUoJ5vp3KweFpeNqxpyGDWyeEPTX2uR6S6syXIm3gj75dM0YJz7NPvcix48mR5laqs8dPuA=="
},
"SkiaSharp.NativeAssets.WebAssembly": {
"type": "Transitive",
"resolved": "2.88.9",
"contentHash": "kt06RccBHSnAs2wDYdBSfsjIDbY3EpsOVqnlDgKdgvyuRA8ZFDaHRdWNx1VHjGgYzmnFCGiTJBnXFl5BqGwGnA=="
},
"SkiaSharp.NativeAssets.Win32": {
"type": "Transitive",
"resolved": "2.88.9",
"contentHash": "wb2kYgU7iy84nQLYZwMeJXixvK++GoIuECjU4ECaUKNuflyRlJKyiRhN1MAHswvlvzuvkrjRWlK0Za6+kYQK7w=="
},
"System.Composition": {
"type": "Transitive",
"resolved": "10.0.1",
"contentHash": "DJkqaWQfho/ReTzKcZD3zJJ6K4GcS154k+T0UCPMBNIOZ2U/lNpyiiWZ6Etw0onWyTH1K+yhICsdmwA5xy2aPQ==",
"dependencies": {
"System.Composition.AttributedModel": "10.0.1",
"System.Composition.Convention": "10.0.1",
"System.Composition.Hosting": "10.0.1",
"System.Composition.Runtime": "10.0.1",
"System.Composition.TypedParts": "10.0.1"
}
},
"System.Composition.AttributedModel": {
"type": "Transitive",
"resolved": "10.0.1",
"contentHash": "mRxYvpCVPAeuLEk0c0kxWJVjbW1/HUoxCgYotOj9eDeQiYcTDOMdCQApsTrHYMN3pHBA8WoF00KGolG632Etaw=="
},
"System.Composition.Convention": {
"type": "Transitive",
"resolved": "10.0.1",
"contentHash": "dYynUByfVBzYDheNPGxS8UN8AvG/4tXf/coSs1odHOyoh4etv1kad/FrLWLMq4f8NO49NV20Xu+0/y613woTUA==",
"dependencies": {
"System.Composition.AttributedModel": "10.0.1"
}
},
"System.Composition.Hosting": {
"type": "Transitive",
"resolved": "10.0.1",
"contentHash": "4UGmyBdKWEN1nkqspJlji/nV7XIVm6KGlOC2So0mtM/gKvaNgLz+tUkcbY+6Zpr7dr6ohX1S5yl0RLID5otRHw==",
"dependencies": {
"System.Composition.Runtime": "10.0.1"
}
},
"System.Composition.Runtime": {
"type": "Transitive",
"resolved": "10.0.1",
"contentHash": "TVfys1gnUIhmXuYfFzyez0fOkDyELe9UwlxYeVlq6FmqmWmt1ouF0OQJ+6ozkHbkaop7uBUaXw7Qb+/o0m+nMg=="
},
"System.Composition.TypedParts": {
"type": "Transitive",
"resolved": "10.0.1",
"contentHash": "koSfjkdQZfgQr3SyiSIBboDn+GiR0vZ3x9Uek9FJbXK0w5AiATV8KrnMEP8B8OAlO+Y3zQf0CPCNzwH+VIYDKg==",
"dependencies": {
"System.Composition.AttributedModel": "10.0.1",
"System.Composition.Hosting": "10.0.1",
"System.Composition.Runtime": "10.0.1"
}
},
"Tmds.DBus.Protocol": {
"type": "Transitive",
"resolved": "0.21.3",
"contentHash": "hDwB8WsQoyALQKqIbwzS68UKdlnafDm4T/DkO/JrA/YIneP/rKv96SxYPVXeh3FP4i/SXfShrYftKLtciJAIlw=="
},
"cfa": {
"type": "Project",
"dependencies": {
"Glob": "[1.1.9, )",
"Microsoft.CodeAnalysis.CSharp.Workspaces": "[5.9.0, )",
"Microsoft.CodeAnalysis.Workspaces.MSBuild": "[5.9.0, )",
"Microsoft.Extensions.Configuration.Json": "[11.0.0-rc.3.26522.148, )",
"Microsoft.Extensions.Options.ConfigurationExtensions": "[11.0.0-rc.3.26522.148, )"
}
},
"Glob": {
"type": "CentralTransitive",
"requested": "[1.1.9, )",
"resolved": "1.1.9",
"contentHash": "AfK5+ECWYTP7G3AAdnU8IfVj+QpGjrh9GC2mpdcJzCvtQ4pnerAGwHsxJ9D4/RnhDUz2DSzd951O/lQjQby2Sw=="
},
"Microsoft.CodeAnalysis.CSharp.Workspaces": {
"type": "CentralTransitive",
"requested": "[5.9.0, )",
"resolved": "5.9.0",
"contentHash": "D2zqK/k16fto0yMz0hcXMTkzOxEwMDJyA1mu/KXF9Befwz4zub3MpHQD8FeRxJtVSSsC3dQFYBw7zu7r/pfO7g==",
"dependencies": {
"Humanizer.Core": "2.14.1",
"Microsoft.CodeAnalysis.Analyzers": "5.9.0-1.26328.17",
"Microsoft.CodeAnalysis.CSharp": "[5.9.0]",
"Microsoft.CodeAnalysis.Common": "[5.9.0]",
"Microsoft.CodeAnalysis.Workspaces.Common": "[5.9.0]",
"System.Composition": "10.0.1"
}
},
"Microsoft.CodeAnalysis.Workspaces.MSBuild": {
"type": "CentralTransitive",
"requested": "[5.9.0, )",
"resolved": "5.9.0",
"contentHash": "BBux6hhD4wXt4lYN2oaY+jV4PnIj5plyqohijgSZtxJQqsvsldPEdii2q9lGrJbNjKEALnldDHz1rmGKG0PeeA==",
"dependencies": {
"Humanizer.Core": "2.14.1",
"Microsoft.Build.Framework": "17.11.48",
"Microsoft.CodeAnalysis.Analyzers": "5.9.0-1.26328.17",
"Microsoft.CodeAnalysis.Workspaces.Common": "[5.9.0]",
"Microsoft.Extensions.DependencyInjection": "10.0.1",
"Microsoft.Extensions.Logging": "10.0.1",
"Microsoft.VisualStudio.SolutionPersistence": "1.0.52",
"System.Composition": "10.0.1"
}
},
"Microsoft.Extensions.Configuration.Json": {
"type": "CentralTransitive",
"requested": "[11.0.0-rc.3.26522.148, )",
"resolved": "11.0.0-rc.3.26522.148",
"contentHash": "HwU/0bRyQm050kBidTu3wG9PiMh0yjehZ8gtDjYLDkJ9voC88sRTaerGRS945flSIcwfg4UuU2QEZKORBzC2Iw==",
"dependencies": {
"Microsoft.Extensions.Configuration": "11.0.0-rc.3.26522.148",
"Microsoft.Extensions.Configuration.FileExtensions": "11.0.0-rc.3.26522.148"
}
},
"Microsoft.Extensions.Options.ConfigurationExtensions": {
"type": "CentralTransitive",
"requested": "[11.0.0-rc.3.26522.148, )",
"resolved": "11.0.0-rc.3.26522.148",
"contentHash": "lkISyAw1jD1Ga97B9VpXTpvXm1YLGfApCRx7LTLzfaBk5YbmiB51U58Uumv+ov66ivhUHl0WdQe6MGFdAhIXjw==",
"dependencies": {
"Microsoft.Extensions.Configuration.Binder": "11.0.0-rc.3.26522.148"
}
}
}
}
}
\ No newline at end of file
ui/tests/Cfa.Ui.Tests.csproj +17 -0
diff --git a/ui/tests/Cfa.Ui.Tests.csproj b/ui/tests/Cfa.Ui.Tests.csproj
new file mode 100644
index 0000000..2d7bbac
@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<RootNamespace>Cfa.Ui.Tests</RootNamespace>
<AssemblyName>Cfa.Ui.Tests</AssemblyName>
<IsTestProject>true</IsTestProject>
<IsPackable>false</IsPackable>
<IsTrimmable>false</IsTrimmable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../src/Cfa.Ui.csproj" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="Shouldly" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" PrivateAssets="all" />
<PackageReference Include="Avalonia.Headless.XUnit" />
</ItemGroup>
</Project>
ui/tests/GraphLayoutTests/BuildShould.cs +515 -0
diff --git a/ui/tests/GraphLayoutTests/BuildShould.cs b/ui/tests/GraphLayoutTests/BuildShould.cs
new file mode 100644
index 0000000..ab5c196
@@ -0,0 +1,515 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Threading;
using Avalonia;
using Cfa.Ui.Graph;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Shouldly;
using Xunit;
namespace Cfa.Ui.Tests.GraphLayoutTests;
public sealed class BuildShould
{
private static readonly INamedTypeSymbol Type = CreateType(
"Root",
"Left",
"Right",
"Leaf",
"Other",
"Shared",
"Tail",
"Helper"
);
[Fact]
public void MirrorIncomingAndOutgoingTreesWithDirectedBoundaryAnchors()
{
// Arrange
var children = Map(("Root", ["Left", "Right"]), ("Left", ["Leaf"]), ("Right", ["Other", "Tail"]));
var analysis = Analysis(children, children);
// Act
var scene = GraphLayout.Build(analysis);
// Assert
var origin = scene.Nodes.Single(n => n.IsOrigin);
origin.Bounds.Center.ShouldBe(new Point(0, 0));
foreach (
var group in scene.Nodes.Where(n => !n.IsOrigin).GroupBy(n => n.Symbol, SymbolEqualityComparer.Default)
)
{
var pair = group.OrderBy(n => n.Location.X).ToArray();
pair.Length.ShouldBe(2);
pair[0].Bounds.Center.X.ShouldBe(-pair[1].Bounds.Center.X, 0.000001);
pair[0].Bounds.Center.Y.ShouldBe(pair[1].Bounds.Center.Y, 0.000001);
}
foreach (var edge in scene.Edges)
{
edge.Start.ShouldBe(new Point(edge.Source.Bounds.Right, edge.Source.Bounds.Center.Y));
edge.End.ShouldBe(new Point(edge.Target.Bounds.Left, edge.Target.Bounds.Center.Y));
edge.Start.X.ShouldBeLessThan(edge.End.X);
}
AssertNoOverlap(scene);
}
[Fact]
public void PreserveSharedOccurrencesAndTheirDescendantsOnSeparateBranches()
{
// Arrange
var analysis = Analysis(
Map(("Root", ["Left", "Right"]), ("Left", ["Shared"]), ("Right", ["Shared"]), ("Shared", ["Tail"]))
);
// Act
var scene = GraphLayout.Build(analysis);
// Assert
scene.Nodes.Count(n => n.Symbol.Name == "Shared").ShouldBe(2);
scene.Nodes.Count(n => n.Symbol.Name == "Tail").ShouldBe(2);
scene.Nodes.Select(n => n.Id).Distinct().Count().ShouldBe(scene.Nodes.Count);
scene.Nodes.ShouldAllBe(n => !n.IsCycleReference);
scene.Edges.Count.ShouldBe(scene.Nodes.Count - 1);
AssertNoOverlap(scene);
}
[Fact]
public void RenderSelfCallsAsOneLoopbackOnTheExistingNode()
{
// Arrange
var calls = Map(("Root", ["Root", "Left"]), ("Left", ["Left"]));
var analysis = Analysis(calls, Map(("Root", ["Root"])));
// Act
var scene = GraphLayout.Build(analysis);
// Assert
scene.Nodes.Count.ShouldBe(2);
var loops = scene.Edges.Where(e => e.IsLoopback).ToArray();
loops.Length.ShouldBe(2);
foreach (var loop in loops)
{
loop.Source.ShouldBeSameAs(loop.Target);
loop.Start.ShouldNotBe(loop.End);
loop.Source.Bounds.Contains(loop.Start).ShouldBeTrue();
loop.Source.Bounds.Contains(loop.End).ShouldBeTrue();
}
scene.Nodes.ShouldAllBe(n => !n.IsCycleReference);
}
[Fact]
public void EndMultiNodeCyclesWithAReferencePerPath()
{
// Arrange
var analysis = Analysis(Map(("Root", ["Left", "Right"]), ("Left", ["Root"]), ("Right", ["Left"])));
// Act
var scene = GraphLayout.Build(analysis);
// Assert
var references = scene.Nodes.Where(n => n.IsCycleReference).ToArray();
references.Length.ShouldBe(2);
references.ShouldAllBe(n => n.Symbol.Name == "Root" && !n.IsOrigin);
references.ShouldAllBe(n => !scene.Edges.Any(e => ReferenceEquals(e.Source, n)));
scene.Nodes.Count(n => n.Symbol.Name == "Left").ShouldBe(2);
scene.Edges.ShouldAllBe(e => !e.IsLoopback);
AssertNoOverlap(scene);
}
[Fact]
public void ProduceDeterministicPositionsAndEdgesWithoutModifyingEarlierScenes()
{
// Arrange
var analysis = Analysis(
Map(
("Root", ["Left", "Right", "Helper"]),
("Left", ["Shared", "Tail"]),
("Right", ["Shared"]),
("Shared", ["Leaf"])
)
);
// Act
var first = GraphLayout.Build(analysis);
var positions = first.Nodes.Select(n => (n.Id, n.Location)).ToArray();
var second = GraphLayout.Build(analysis);
// Assert
second.Nodes.Select(n => (n.Id, n.Location)).ShouldBe(positions);
first.Nodes.Select(n => (n.Id, n.Location)).ShouldBe(positions);
second
.Edges.Select(e => (e.Source.Id, e.Target.Id, e.Start, e.End))
.ShouldBe(first.Edges.Select(e => (e.Source.Id, e.Target.Id, e.Start, e.End)));
AssertNoOverlap(second);
}
[Fact]
public void UseReadableShrinkingNodesAndIncreasingLinkDistances()
{
// Arrange
var call = new CallAnalysis(Symbol("Left"), LinkKind.Direct, CallReach.SameType);
var once = GraphLayout.CalculateNodeSize(call, 1);
// Act
var repeated = GraphLayout.CalculateNodeSize(call, 32);
var frequent = GraphLayout.CalculateNodeSize(call, uint.MaxValue);
var kinds = new[] { LinkKind.Abstract, LinkKind.Direct, LinkKind.Event };
var reaches = Enum.GetValues<CallReach>();
// Assert
repeated.Width.ShouldBeLessThan(once.Width);
repeated.Height.ShouldBeLessThan(once.Height);
frequent.Width.ShouldBeGreaterThan(100);
frequent.Height.ShouldBeGreaterThan(30);
foreach (var reach in reaches)
{
var lengths = kinds
.Select(kind => GraphLayout.CalculateEdgeLength(call with { LinkKind = kind, Reach = reach }))
.ToArray();
lengths[0].ShouldBeLessThan(lengths[1]);
lengths[1].ShouldBeLessThan(lengths[2]);
}
foreach (var kind in kinds)
{
var lengths = reaches
.Select(reach => GraphLayout.CalculateEdgeLength(call with { LinkKind = kind, Reach = reach }))
.ToArray();
for (var i = 1; i < lengths.Length; i++)
lengths[i].ShouldBeGreaterThan(lengths[i - 1]);
}
GraphLayout
.CalculateThickness(call with { LinkKind = LinkKind.Abstract })
.ShouldBeLessThan(GraphLayout.CalculateThickness(call));
GraphLayout
.CalculateThickness(call with { LinkKind = LinkKind.Event })
.ShouldBeGreaterThan(GraphLayout.CalculateThickness(call));
}
[Fact]
public void EmphasizeEventsAndCrossAssemblyCallsWhileShrinkingRepeatedOccurrences()
{
// Arrange
var local = new CallAnalysis(Symbol("Helper"), LinkKind.Direct, CallReach.SameType);
var eventHandler = local with { LinkKind = LinkKind.Event };
var external = local with { Reach = CallReach.CrossAssembly };
var externalEvent = eventHandler with { Reach = CallReach.CrossAssembly };
var localSize = GraphLayout.CalculateNodeSize(local, 1);
// Act
var eventSize = GraphLayout.CalculateNodeSize(eventHandler, 1);
var externalSize = GraphLayout.CalculateNodeSize(external, 1);
var prominentSize = GraphLayout.CalculateNodeSize(externalEvent, 1);
var repeatedSize = GraphLayout.CalculateNodeSize(externalEvent, 16);
var frequentSize = GraphLayout.CalculateNodeSize(externalEvent, uint.MaxValue);
// Assert
eventSize.Width.ShouldBeGreaterThan(localSize.Width);
eventSize.Height.ShouldBeGreaterThan(localSize.Height);
externalSize.Width.ShouldBeGreaterThan(localSize.Width);
externalSize.Height.ShouldBeGreaterThan(localSize.Height);
prominentSize.Width.ShouldBeGreaterThan(eventSize.Width);
prominentSize.Height.ShouldBeGreaterThan(eventSize.Height);
prominentSize.Width.ShouldBeGreaterThan(externalSize.Width);
prominentSize.Height.ShouldBeGreaterThan(externalSize.Height);
repeatedSize.Width.ShouldBeLessThan(prominentSize.Width);
repeatedSize.Height.ShouldBeLessThan(prominentSize.Height);
frequentSize.Width.ShouldBeGreaterThan(100);
frequentSize.Height.ShouldBeGreaterThan(30);
}
[Fact]
public void ApplyReachAndLinkKindToActualSingleBranchSpacing()
{
// Arrange
var target = Symbol("Left");
var calls = Enum.GetValues<LinkKind>()
.SelectMany(kind => Enum.GetValues<CallReach>().Select(reach => new CallAnalysis(target, kind, reach)))
.ToArray();
// Act
var scenes = calls
.Select(call => GraphLayout.Build(Analysis(EmptyMap().Add(Symbol("Root"), ImmutableList.Create(call)))))
.ToArray();
// Assert
for (var i = 0; i < scenes.Length; i++)
{
var edge = scenes[i].Edges.Single();
(edge.End.X - edge.Start.X).ShouldBe(GraphLayout.CalculateEdgeLength(calls[i]), 0.000001);
edge.Start.Y.ShouldBe(edge.End.Y);
}
}
[Fact]
public void IncludeLoopCurvesAndArrowheadsInSceneBounds()
{
// Arrange
var analysis = Analysis(Map(("Root", ["Left"]), ("Left", ["Left"])));
// Act
var scene = GraphLayout.Build(analysis);
// Assert
var loop = scene.Edges.Single(e => e.IsLoopback);
scene.Bounds.Right.ShouldBeGreaterThan(scene.Nodes.Max(n => n.Bounds.Right));
scene.Bounds.Top.ShouldBeLessThan(scene.Nodes.Min(n => n.Bounds.Top));
foreach (var node in scene.Nodes)
scene.Bounds.Contains(node.Bounds).ShouldBeTrue();
foreach (var edge in scene.Edges)
{
for (var i = 0; i <= 100; i++)
{
var t = i / 100d;
var u = 1 - t;
var point = new Point(
u * u * u * edge.Start.X
+ 3 * u * u * t * edge.Control1.X
+ 3 * u * t * t * edge.Control2.X
+ t * t * t * edge.End.X,
u * u * u * edge.Start.Y
+ 3 * u * u * t * edge.Control1.Y
+ 3 * u * t * t * edge.Control2.Y
+ t * t * t * edge.End.Y
);
scene.Bounds.Contains(new Rect(point, default(Size)).Inflate(edge.Thickness / 2)).ShouldBeTrue();
}
var direction = new Vector(edge.End.X - edge.Control2.X, edge.End.Y - edge.Control2.Y);
direction /= direction.Length;
var normal = new Vector(-direction.Y, direction.X);
scene.Bounds.Contains(edge.End - direction * 13 + normal * 6).ShouldBeTrue();
scene.Bounds.Contains(edge.End - direction * 13 - normal * 6).ShouldBeTrue();
}
}
[Fact]
public void ClusterSmallHelpersOnASemicircleWithoutOverlap()
{
// Arrange
var analysis = Analysis(Map(("Root", ["Left", "Helper", "Right"])));
// Act
var scene = GraphLayout.Build(analysis);
// Assert
var edges = scene.Edges.ToArray();
edges[0].End.Y.ShouldBe(-edges[2].End.Y);
edges[0].End.X.ShouldBe(edges[2].End.X);
edges[1].End.Y.ShouldBe(0);
edges[1].End.X.ShouldBeGreaterThan(edges[0].End.X);
foreach (var edge in edges)
{
var distance = new Vector(edge.End.X - edge.Start.X, edge.End.Y - edge.Start.Y).Length;
distance.ShouldBe(
GraphLayout.CalculateEdgeLength(
new CallAnalysis(edge.Target.Symbol, LinkKind.Direct, CallReach.SameType)
),
0.000001
);
}
AssertNoOverlap(scene);
}
[Fact]
public void PreserveIdenticalSourceMembersFromDifferentProjectCompilations()
{
// Arrange
var tree = CSharpSyntaxTree.ParseText(
"class Linked { void Shared(int value) { } }",
path: "/project/Linked.cs"
);
var first = CSharpCompilation
.Create("ProjectOne", [tree])
.GetTypeByMetadataName("Linked")!
.GetMembers("Shared")
.Single();
var second = CSharpCompilation
.Create("ProjectTwo", [tree])
.GetTypeByMetadataName("Linked")!
.GetMembers("Shared")
.Single();
var analysis = Analysis(
EmptyMap()
.Add(
Symbol("Root"),
ImmutableList.Create(
new CallAnalysis(first, LinkKind.Direct, CallReach.CrossAssembly),
new CallAnalysis(second, LinkKind.Direct, CallReach.CrossAssembly)
)
)
);
// Act
var nodes = GraphLayout.Build(analysis).Nodes.Where(n => !n.IsOrigin).ToArray();
// Assert
nodes.Length.ShouldBe(2);
nodes[0].Symbol.ShouldBeSameAs(first);
nodes[1].Symbol.ShouldBeSameAs(second);
nodes[0].Label.ShouldBe("Shared(int value)");
nodes[1].Label.ShouldBe(nodes[0].Label);
nodes[0].SourceLink.ShouldStartWith("file:///project/Linked.cs#1:");
nodes[1].SourceLink.ShouldBe(nodes[0].SourceLink);
nodes.ShouldAllBe(n => !n.IsCycleReference);
}
[Fact]
public void FormatSignaturesAndOneBasedSourceLinksWithoutContainingNames()
{
// Arrange
var tree = CSharpSyntaxTree.ParseText(
"namespace Example { class Container { public string Root(int value, string text) => text; } }",
path: "/project/Example.cs"
);
var compilation = CSharpCompilation.Create("Labels", [tree]);
var symbol = compilation.GetTypeByMetadataName("Example.Container")!.GetMembers("Root").Single();
var analysis = new CallGraphAnalysis(
symbol,
EmptyMap(),
EmptyMap(),
ImmutableDictionary.Create<ISymbol, uint>(SymbolEqualityComparer.Default)
);
// Act
var node = GraphLayout.Build(analysis).Nodes.Single();
// Assert
node.Label.ShouldBe("Root(int value, string text)");
var start = tree.GetLineSpan(symbol.DeclaringSyntaxReferences[0].Span).StartLinePosition;
node.SourceLink.ShouldBe($"file:///project/Example.cs#{start.Line + 1}:{start.Character + 1}");
node.Label.ShouldNotContain("Container");
node.Label.ShouldNotContain("Example");
}
[Fact]
public void FormatConstructorsAndPropertiesWithSimpleNames()
{
// Arrange
var tree = CSharpSyntaxTree.ParseText(
"namespace Example { class Container { public Container(int id) { } public string Name { get; set; } public string this[int index] => Name; } }"
);
var type = CSharpCompilation.Create("Members", [tree]).GetTypeByMetadataName("Example.Container")!;
var members = new[]
{
type.InstanceConstructors.Single(),
type.GetMembers("Name").Single(),
type.GetMembers().OfType<IPropertySymbol>().Single(p => p.IsIndexer),
};
// Act
var labels = members
.Select(symbol =>
GraphLayout
.Build(
new CallGraphAnalysis(
symbol,
EmptyMap(),
EmptyMap(),
ImmutableDictionary.Create<ISymbol, uint>(SymbolEqualityComparer.Default)
)
)
.Nodes.Single()
.Label
)
.ToArray();
// Assert
labels.ShouldBe(new[] { "Container(int id)", "Name", "this[int index]" });
}
[Fact]
public void SupportDeepGraphsWithoutRecursiveTraversalOrHiddenDepthLimits()
{
// Arrange
const int depth = 3000;
var type = CreateType(Enumerable.Range(0, depth).Select(i => $"M{i}").ToArray());
var symbols = Enumerable.Range(0, depth).Select(i => type.GetMembers($"M{i}").Single()).ToArray();
var builder = ImmutableDictionary.CreateBuilder<ISymbol, IImmutableList<CallAnalysis>>(
SymbolEqualityComparer.Default
);
for (var i = 0; i < depth - 1; i++)
builder.Add(
symbols[i],
ImmutableList.Create(new CallAnalysis(symbols[i + 1], LinkKind.Direct, CallReach.SameType))
);
var analysis = new CallGraphAnalysis(
symbols[0],
builder.ToImmutable(),
EmptyMap(),
ImmutableDictionary.Create<ISymbol, uint>(SymbolEqualityComparer.Default)
);
// Act
var scene = GraphLayout.Build(analysis);
// Assert
scene.Nodes.Count.ShouldBe(depth);
scene.Edges.Count.ShouldBe(depth - 1);
scene.Nodes[^1].Location.X.ShouldBeGreaterThan(scene.Nodes[^2].Bounds.Right);
}
[Fact]
public void HonorCancellation()
{
// Arrange
var analysis = Analysis(Map(("Root", ["Left"])));
using var cancellation = new CancellationTokenSource();
cancellation.Cancel();
// Act
var build = () => GraphLayout.Build(analysis, cancellation.Token);
// Assert
build.ShouldThrow<OperationCanceledException>();
}
private static INamedTypeSymbol CreateType(params string[] methods)
{
var source = "class Methods { " + string.Join(" ", methods.Select(name => $"void {name}() {{ }}")) + " }";
return CSharpCompilation
.Create("GraphLayoutTests", [CSharpSyntaxTree.ParseText(source)])
.GetTypeByMetadataName("Methods")!;
}
private static ISymbol Symbol(string name) => Type.GetMembers(name).Single();
private static IImmutableDictionary<ISymbol, IImmutableList<CallAnalysis>> EmptyMap() =>
ImmutableDictionary.Create<ISymbol, IImmutableList<CallAnalysis>>(SymbolEqualityComparer.Default);
private static IImmutableDictionary<ISymbol, IImmutableList<CallAnalysis>> Map(
params (string Source, string[] Targets)[] entries
) =>
entries.Aggregate(
EmptyMap(),
(map, entry) =>
map.Add(
Symbol(entry.Source),
entry
.Targets.Select(name => new CallAnalysis(Symbol(name), LinkKind.Direct, CallReach.SameType))
.ToImmutableList()
)
);
private static CallGraphAnalysis Analysis(
IImmutableDictionary<ISymbol, IImmutableList<CallAnalysis>> outgoing,
IImmutableDictionary<ISymbol, IImmutableList<CallAnalysis>>? incoming = null
) =>
new(
Symbol("Root"),
outgoing,
incoming ?? EmptyMap(),
ImmutableDictionary.Create<ISymbol, uint>(SymbolEqualityComparer.Default)
);
private static void AssertNoOverlap(GraphScene scene)
{
for (var i = 0; i < scene.Nodes.Count; i++)
for (var j = i + 1; j < scene.Nodes.Count; j++)
scene
.Nodes[i]
.Bounds.Intersects(scene.Nodes[j].Bounds)
.ShouldBeFalse($"Nodes {scene.Nodes[i].Id} and {scene.Nodes[j].Id} overlap");
}
}
ui/tests/MainWindowTests/InteractShould.cs +361 -0
diff --git a/ui/tests/MainWindowTests/InteractShould.cs b/ui/tests/MainWindowTests/InteractShould.cs
new file mode 100644
index 0000000..376f66f
@@ -0,0 +1,361 @@
using System;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Headless;
using Avalonia.Headless.XUnit;
using Avalonia.Input;
using Avalonia.Threading;
using Avalonia.VisualTree;
using Cfa.Ui.Graph;
using Cfa.Ui.Services;
using Cfa.Ui.ViewModels;
using Cfa.Ui.Views;
using Nodify;
using Shouldly;
namespace Cfa.Ui.Tests.MainWindowTests;
public sealed class InteractShould
{
[AvaloniaFact]
public async Task KeepTheAddressDisabledWhileTheSolutionLoads()
{
// Arrange
var factory = new FakeFactory
{
Pending = new TaskCompletionSource<IScanSession>(TaskCreationOptions.RunContinuationsAsynchronously),
};
using var model = new MainViewModel(new FakeDesktop(), factory);
var window = new MainWindow { DataContext = model };
window.Show();
try
{
// Act
var loading = model.OpenSolutionCommand.ExecuteAsync(null);
// Assert
model.IsBusy.ShouldBeTrue();
model.OpenSolutionCommand.CanExecute(null).ShouldBeFalse();
window.FindControl<TextBox>("AddressBar")!.IsEnabled.ShouldBeFalse();
factory.Pending.SetResult(factory.Session);
await loading;
model.IsBusy.ShouldBeFalse();
window.FindControl<TextBox>("AddressBar")!.IsEnabled.ShouldBeTrue();
}
finally
{
window.Close();
}
}
[AvaloniaFact]
public async Task PanAndZoomWithoutMovingNodesOrOpeningTheirLinks()
{
// Arrange
var desktop = new FakeDesktop();
using var model = new MainViewModel(desktop, new FakeFactory());
var window = new MainWindow { DataContext = model };
window.Show();
try
{
await model.OpenSolutionCommand.ExecuteAsync(null);
model.Address = "Flow.cs:1";
await model.AnalyzeCommand.ExecuteAsync(null);
Dispatcher.UIThread.RunJobs();
window.UpdateLayout();
var editor = window.FindControl<NodifyEditor>("Editor")!;
var initialZoom = editor.ViewportZoom;
var initialViewport = editor.ViewportLocation;
var positions = model.Scene!.Nodes.Select(node => node.Location).ToArray();
var point = editor.TranslatePoint(new Point(120, 120), window)!.Value;
// Act
window.MouseDown(point, MouseButton.Right);
window.MouseMove(point + new Point(90, 40), RawInputModifiers.RightMouseButton);
window.MouseUp(point + new Point(90, 40), MouseButton.Right);
window.MouseWheel(point, new Vector(0, 1));
// Assert
editor.ViewportLocation.ShouldNotBe(initialViewport);
editor.ViewportZoom.ShouldBeGreaterThan(initialZoom);
model.Scene.Nodes.Select(node => node.Location).ShouldBe(positions);
desktop.OpenedLink.ShouldBeNull();
}
finally
{
window.Close();
}
}
[AvaloniaFact]
public async Task DisplayAnErrorPopupThatCanBeDismissed()
{
// Arrange
var window = new MainWindow();
window.Show();
var service = new DesktopServices(window);
try
{
// Act
var showing = service.ShowErrorAsync("Example failure");
var dialog = window.OwnedWindows.ShouldHaveSingleItem();
dialog.UpdateLayout();
var close = dialog.GetVisualDescendants().OfType<Button>().ShouldHaveSingleItem();
var point = close.TranslatePoint(new Point(close.Bounds.Width / 2, close.Bounds.Height / 2), dialog)!.Value;
dialog.MouseDown(point, MouseButton.Left);
dialog.MouseUp(point, MouseButton.Left);
await showing;
// Assert
window.OwnedWindows.ShouldBeEmpty();
}
finally
{
window.Close();
}
}
[AvaloniaFact]
public async Task LoadOnceExploreWithEnterAndOpenTheClickedSourceLink()
{
// Arrange
var desktop = new FakeDesktop();
var factory = new FakeFactory();
using var model = new MainViewModel(desktop, factory);
var window = new MainWindow { DataContext = model };
window.Show();
try
{
var address = window.FindControl<TextBox>("AddressBar")!;
address.IsEnabled.ShouldBeFalse();
// Act
window.KeyPressQwerty(PhysicalKey.O, RawInputModifiers.Control);
window.KeyReleaseQwerty(PhysicalKey.O, RawInputModifiers.Control);
await model.OpenSolutionCommand.ExecutionTask!;
address.IsEnabled.ShouldBeTrue();
address.Text = "core/src/Scanner.cs:50";
address.Focus();
window.KeyPressQwerty(PhysicalKey.Enter, RawInputModifiers.None);
window.KeyReleaseQwerty(PhysicalKey.Enter, RawInputModifiers.None);
await model.AnalyzeCommand.ExecutionTask!;
Dispatcher.UIThread.RunJobs();
window.UpdateLayout();
var button = window
.GetVisualDescendants()
.OfType<Button>()
.First(control => control.DataContext is GraphNode);
var node = (GraphNode)button.DataContext!;
var point = button
.TranslatePoint(new Point(button.Bounds.Width / 2, button.Bounds.Height / 2), window)!
.Value;
window.MouseDown(point, MouseButton.Left);
window.MouseUp(point, MouseButton.Left);
if (model.OpenSourceCommand.ExecutionTask is { } openTask)
{
await openTask;
}
await model.AnalyzeCommand.ExecuteAsync(null);
// Assert
factory.Opens.ShouldBe(1);
factory.Session.Analyses.ShouldBe(2);
factory.Session.LastFile!.FullName.ShouldBe("/repo/core/src/Scanner.cs");
factory.Session.LastLine.ShouldBe(50);
desktop.OpenedLink.ShouldBe(node.SourceLink);
desktop.OpenedLink.ShouldStartWith("file:///repo/Flow.cs#");
desktop.Error.ShouldBeNull();
window.FindControl<NodifyEditor>("Editor")!.ItemsSource.ShouldNotBeNull();
window.GetVisualDescendants().OfType<ItemContainer>().ShouldAllBe(container => !container.IsDraggable);
}
finally
{
window.Close();
}
}
[AvaloniaFact]
public async Task ShowBusyStateAndReportErrorsWithoutReplacingThePreviousGraph()
{
// Arrange
var desktop = new FakeDesktop();
var factory = new FakeFactory();
using var model = new MainViewModel(desktop, factory);
var window = new MainWindow { DataContext = model };
window.Show();
try
{
await model.OpenSolutionCommand.ExecuteAsync(null);
model.Address = "Flow.cs:1";
await model.AnalyzeCommand.ExecuteAsync(null);
var scene = model.Scene;
factory.Session.Pending = new TaskCompletionSource<CallGraphAnalysis>(
TaskCreationOptions.RunContinuationsAsynchronously
);
// Act
var analysis = model.AnalyzeCommand.ExecuteAsync(null);
model.IsBusy.ShouldBeTrue();
window.FindControl<TextBox>("AddressBar")!.IsEnabled.ShouldBeFalse();
factory.Session.Pending.SetException(
new InvalidOperationException("The selected source cannot be analyzed.")
);
await analysis;
// Assert
model.IsBusy.ShouldBeFalse();
model.Scene.ShouldBeSameAs(scene);
desktop.Error.ShouldBe("The selected source cannot be analyzed.");
window.FindControl<TextBox>("AddressBar")!.IsEnabled.ShouldBeTrue();
}
finally
{
window.Close();
}
}
[AvaloniaFact]
public async Task RenderMirroredBranchesArrowsAndSelfLoopWithoutExtraSelfNode()
{
// Arrange
var desktop = new FakeDesktop();
using var model = new MainViewModel(desktop, new FakeFactory());
var window = new MainWindow { DataContext = model };
window.Show();
try
{
await model.OpenSolutionCommand.ExecuteAsync(null);
model.Address = "Flow.cs:1";
// Act
await model.AnalyzeCommand.ExecuteAsync(null);
Dispatcher.UIThread.RunJobs();
window.UpdateLayout();
window.CaptureRenderedFrame()?.Dispose();
using var frame = window.CaptureRenderedFrame().ShouldNotBeNull();
// Assert
var scene = model.Scene.ShouldNotBeNull();
scene.Nodes.Count.ShouldBe(5);
scene.Nodes.Single(node => node.Symbol.Name == "Request").Location.X.ShouldBeLessThan(0);
scene.Nodes.Single(node => node.Symbol.Name == "Handle").Location.X.ShouldBeGreaterThan(0);
scene.Edges.ShouldContain(edge => edge.IsLoopback && edge.Source == edge.Target);
var buttons = window
.GetVisualDescendants()
.OfType<Button>()
.Where(button => button.DataContext is GraphNode)
.ToArray();
buttons.Length.ShouldBe(scene.Nodes.Count);
buttons.ShouldAllBe(button => button.Bounds.Width > 0 && button.Bounds.Height > 0);
var editor = window.FindControl<NodifyEditor>("Editor")!;
var origin = buttons.Single(button => ((GraphNode)button.DataContext!).IsOrigin);
var originCenter = origin
.TranslatePoint(new Point(origin.Bounds.Width / 2, origin.Bounds.Height / 2), editor)!
.Value;
originCenter.X.ShouldBe(editor.Bounds.Width / 2, 2);
originCenter.Y.ShouldBe(editor.Bounds.Height / 2, 2);
window.GetVisualDescendants().OfType<GraphConnection>().Count().ShouldBe(scene.Edges.Count);
frame.PixelSize.Width.ShouldBeGreaterThan(800);
var stride = frame.PixelSize.Width * 4;
var pixels = new byte[stride * frame.PixelSize.Height];
var buffer = Marshal.AllocHGlobal(pixels.Length);
try
{
frame.CopyPixels(new PixelRect(frame.PixelSize), buffer, pixels.Length, stride);
Marshal.Copy(buffer, pixels, 0, pixels.Length);
}
finally
{
Marshal.FreeHGlobal(buffer);
}
// The background dots are muted; bright cyan marks the actual nodes and arrowed edges.
foreach (var half in new[] { 0, frame.PixelSize.Width / 2 })
{
var colored = 0;
for (var y = 110; y < frame.PixelSize.Height - 40; y++)
{
for (var x = half; x < half + frame.PixelSize.Width / 2; x++)
{
var index = y * stride + x * 4;
if (
pixels[index + 1] > 130
&& (
(pixels[index] > 170 && pixels[index + 2] < 160)
|| (pixels[index + 2] > 170 && pixels[index] < 160)
)
)
{
colored++;
}
}
}
colored.ShouldBeGreaterThan(200);
}
if (Environment.GetEnvironmentVariable("CFA_UI_ARTIFACTS") is { } directory)
{
Directory.CreateDirectory(directory);
frame.Save(Path.Combine(directory, "call-graph.png"));
}
}
finally
{
window.Close();
}
}
private sealed class FakeDesktop : IDesktopServices
{
public string? Error { get; private set; }
public string? OpenedLink { get; private set; }
public Task<FileInfo?> PickSolutionAsync() => Task.FromResult<FileInfo?>(new FileInfo("/repo/Example.slnx"));
public Task ShowErrorAsync(string message)
{
Error = message;
return Task.CompletedTask;
}
public Task OpenSourceAsync(string link)
{
OpenedLink = link;
return Task.CompletedTask;
}
}
private sealed class FakeFactory : IScanSessionFactory
{
public int Opens { get; private set; }
public TaskCompletionSource<IScanSession>? Pending { get; set; }
public FakeSession Session { get; } = new();
public Task<IScanSession> OpenAsync(FileInfo solution, CancellationToken token)
{
Opens++;
return Pending?.Task ?? Task.FromResult<IScanSession>(Session);
}
}
private sealed class FakeSession : IScanSession
{
public int Analyses { get; private set; }
public FileInfo? LastFile { get; private set; }
public int LastLine { get; private set; }
public TaskCompletionSource<CallGraphAnalysis>? Pending { get; set; }
public Task<CallGraphAnalysis> AnalyzeAsync(FileInfo file, int line, CancellationToken token)
{
Analyses++;
LastFile = file;
LastLine = line;
return Pending?.Task ?? Task.FromResult(TestGraph.Create());
}
public void Dispose() { }
}
}
ui/tests/ScanSessionFactoryTests/OpenAsyncShould.cs +43 -0
diff --git a/ui/tests/ScanSessionFactoryTests/OpenAsyncShould.cs b/ui/tests/ScanSessionFactoryTests/OpenAsyncShould.cs
new file mode 100644
index 0000000..ec9de04
@@ -0,0 +1,43 @@
using System;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Cfa.Ui.Graph;
using Cfa.Ui.Services;
using Shouldly;
using Xunit;
namespace Cfa.Ui.Tests.ScanSessionFactoryTests;
public sealed class OpenAsyncShould
{
[Fact]
public async Task LoadTheCfaSolutionAndReuseTheSessionForSeveralQueries()
{
// Arrange
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "code-flow-analysis.slnx")))
{
directory = directory.Parent;
}
var root = directory.ShouldNotBeNull().FullName;
var solution = new FileInfo(Path.Combine(root, "code-flow-analysis.slnx"));
var source = new FileInfo(Path.Combine(root, "core/src/CompilationsExtensions.cs"));
var lines = await File.ReadAllLinesAsync(source.FullName);
var line = Array.FindIndex(lines, text => text.Contains("public static SemanticModel GetSemanticModel")) + 1;
using var timeout = new CancellationTokenSource(TimeSpan.FromMinutes(2));
var factory = new ScanSessionFactory();
// Act
using var session = await factory.OpenAsync(solution, timeout.Token);
var first = await session.AnalyzeAsync(source, line, timeout.Token);
var second = await session.AnalyzeAsync(source, line, timeout.Token);
var scene = GraphLayout.Build(second, timeout.Token);
// Assert
first.Origin.Name.ShouldBe("GetSemanticModel");
second.Origin.ShouldBe(first.Origin);
scene.Nodes.Where(node => node.IsOrigin).ShouldHaveSingleItem().Symbol.ShouldBe(second.Origin);
}
}
ui/tests/TestApp.cs +17 -0
diff --git a/ui/tests/TestApp.cs b/ui/tests/TestApp.cs
new file mode 100644
index 0000000..1a71f21
@@ -0,0 +1,17 @@
using Avalonia;
using Avalonia.Headless;
using Avalonia.Headless.XUnit;
[assembly: AvaloniaTestApplication(typeof(Cfa.Ui.Tests.TestApp))]
namespace Cfa.Ui.Tests;
public static class TestApp
{
public static AppBuilder BuildAvaloniaApp() =>
AppBuilder
.Configure<App>()
.WithInterFont()
.UseSkia()
.UseHeadless(new AvaloniaHeadlessPlatformOptions { UseHeadlessDrawing = false });
}
ui/tests/TestGraph.cs +57 -0
diff --git a/ui/tests/TestGraph.cs b/ui/tests/TestGraph.cs
new file mode 100644
index 0000000..e8bcca9
@@ -0,0 +1,57 @@
using System.Collections.Immutable;
using System.Linq;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
namespace Cfa.Ui.Tests;
internal static class TestGraph
{
public static CallGraphAnalysis Create()
{
var tree = CSharpSyntaxTree.ParseText(
"""
class Flow
{
void Request(int id) { }
void Entry(int id) { }
void Validate(int id) { }
void Handle(int id) { }
void Save(int id) { }
}
""",
path: "/repo/Flow.cs"
);
var compilation = CSharpCompilation.Create(
"Example",
[tree],
[MetadataReference.CreateFromFile(typeof(object).Assembly.Location)],
new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)
);
var members = compilation
.GetTypeByMetadataName("Flow")!
.GetMembers()
.OfType<IMethodSymbol>()
.ToDictionary(method => method.Name);
var outgoing = ImmutableDictionary.CreateBuilder<ISymbol, IImmutableList<CallAnalysis>>(
SymbolEqualityComparer.Default
);
var incoming = ImmutableDictionary.CreateBuilder<ISymbol, IImmutableList<CallAnalysis>>(
SymbolEqualityComparer.Default
);
outgoing[members["Entry"]] =
[
new(members["Validate"], LinkKind.Direct, CallReach.SameType),
new(members["Handle"], LinkKind.Event, CallReach.CrossAssembly),
];
outgoing[members["Handle"]] = [new(members["Save"], LinkKind.Direct, CallReach.SameType)];
outgoing[members["Save"]] = [new(members["Save"], LinkKind.Direct, CallReach.SameType)];
incoming[members["Entry"]] = [new(members["Request"], LinkKind.Direct, CallReach.CrossAssembly)];
var appearances = ImmutableDictionary.CreateBuilder<ISymbol, uint>(SymbolEqualityComparer.Default);
foreach (var member in members.Values)
{
appearances[member] = 1;
}
return new(members["Entry"], outgoing.ToImmutable(), incoming.ToImmutable(), appearances.ToImmutable());
}
}
ui/tests/packages.lock.json +573 -0
diff --git a/ui/tests/packages.lock.json b/ui/tests/packages.lock.json
new file mode 100644
index 0000000..d280144
@@ -0,0 +1,573 @@
{
"version": 2,
"dependencies": {
"net11.0": {
"Avalonia.Headless.XUnit": {
"type": "Direct",
"requested": "[11.3.22, )",
"resolved": "11.3.22",
"contentHash": "L49Z7aX9UXkwTUAtpyyNM9BHjhV/GZOy6V3VeKxC4A0LIBam1+8biF1Sl6EtDih0Z6NrsuocNrKhP7+wEAKDjQ==",
"dependencies": {
"Avalonia.Headless": "11.3.22",
"xunit.core": "2.4.0"
}
},
"CSharpier.MsBuild": {
"type": "Direct",
"requested": "[1.3.0, )",
"resolved": "1.3.0",
"contentHash": "jzzLpmz7F6LyBh5FTZy8/4dEYVcAG9VjBPKryZ82LIoUuupcBkCSXzaeS/4oYcp/rBC8yVLcDstJ8CIv9n2IFw=="
},
"Microsoft.NET.Test.Sdk": {
"type": "Direct",
"requested": "[17.14.1, )",
"resolved": "17.14.1",
"contentHash": "HJKqKOE+vshXra2aEHpi2TlxYX7Z9VFYkr+E5rwEvHC8eIXiyO+K9kNm8vmNom3e2rA56WqxU+/N9NJlLGXsJQ==",
"dependencies": {
"Microsoft.CodeCoverage": "17.14.1",
"Microsoft.TestPlatform.TestHost": "17.14.1"
}
},
"Shouldly": {
"type": "Direct",
"requested": "[4.3.0, )",
"resolved": "4.3.0",
"contentHash": "sDetrWXrl6YXZ4HeLsdBoNk3uIa7K+V4uvIJ+cqdRa5DrFxeTED7VkjoxCuU1kJWpUuBDZz2QXFzSxBtVXLwRQ==",
"dependencies": {
"DiffEngine": "11.3.0",
"EmptyFiles": "4.4.0"
}
},
"xunit": {
"type": "Direct",
"requested": "[2.9.3, )",
"resolved": "2.9.3",
"contentHash": "TlXQBinK35LpOPKHAqbLY4xlEen9TBafjs0V5KnA4wZsoQLQJiirCR4CbIXvOH8NzkW4YeJKP5P/Bnrodm0h9Q==",
"dependencies": {
"xunit.analyzers": "1.18.0",
"xunit.assert": "2.9.3",
"xunit.core": "[2.9.3]"
}
},
"xunit.runner.visualstudio": {
"type": "Direct",
"requested": "[3.1.4, )",
"resolved": "3.1.4",
"contentHash": "5mj99LvCqrq3CNi06xYdyIAXOEh+5b33F2nErCzI5zWiDdLHXiPXEWFSUAF8zlIv0ZWqjZNCwHTQeAPYbF3pCg=="
},
"Avalonia.Angle.Windows.Natives": {
"type": "Transitive",
"resolved": "2.1.25547.20250602",
"contentHash": "ZL0VLc4s9rvNNFt19Pxm5UNAkmKNylugAwJPX9ulXZ6JWs/l6XZihPWWTyezaoNOVyEPU8YbURtW7XMAtqXH5A=="
},
"Avalonia.BuildServices": {
"type": "Transitive",
"resolved": "11.3.2",
"contentHash": "qHDToxto1e3hci5YqbG9n0Ty8mlp3zBUN5wT66wKqaDVzXyQ0do3EnRILd4Ke9jpvsktaPpgE0YjEk7hornryQ=="
},
"Avalonia.FreeDesktop": {
"type": "Transitive",
"resolved": "11.3.22",
"contentHash": "heXRjopzKbNVGnLI+YPM6VBcf+cKGKKNdyXBYAlAlbcPfkExOTrGBSPDSSSHaT9uEByqZ4xo/9T0IqDtGm2PAQ==",
"dependencies": {
"Avalonia": "11.3.22",
"Tmds.DBus.Protocol": "0.21.3"
}
},
"Avalonia.Headless": {
"type": "Transitive",
"resolved": "11.3.22",
"contentHash": "yb0+VpNVTDW2ok6SBiICk4/iOoPoH6fudqqCV8BPuW5mHqpxC2wko+9AiYs0ak0OiXYcvhmrywWenPgmaDKoPQ==",
"dependencies": {
"Avalonia": "11.3.22"
}
},
"Avalonia.Native": {
"type": "Transitive",
"resolved": "11.3.22",
"contentHash": "sU/KJQKqrRkstS1EWIhRvD/ADIkItoOSss3sL/wtPFFFJINPOrge5qMnwNi0RbDzNKb4TrZwEv6eeD27Ip+ACQ==",
"dependencies": {
"Avalonia": "11.3.22"
}
},
"Avalonia.Remote.Protocol": {
"type": "Transitive",
"resolved": "11.3.22",
"contentHash": "Ll5YswLkNW0WZm7RkvGS+8B9vH4/x77IsnCqlBvmXOgiQd0J5bpJQFYo1NDVyRLadp4e8Yqg1WP2xEc6IK94HQ=="
},
"Avalonia.Skia": {
"type": "Transitive",
"resolved": "11.3.22",
"contentHash": "ZbnfFCr2f1l3RD1E/Tn5tJDRFIDm9ICY/PzG4lhuShd6mDg+0y5wRCytCYOSM79KhlboU/f0O7J1Mw7s83Sy3A==",
"dependencies": {
"Avalonia": "11.3.22",
"HarfBuzzSharp": "8.3.1.1",
"HarfBuzzSharp.NativeAssets.Linux": "8.3.1.1",
"HarfBuzzSharp.NativeAssets.WebAssembly": "8.3.1.1",
"SkiaSharp": "2.88.9",
"SkiaSharp.NativeAssets.Linux": "2.88.9",
"SkiaSharp.NativeAssets.WebAssembly": "2.88.9"
}
},
"Avalonia.Win32": {
"type": "Transitive",
"resolved": "11.3.22",
"contentHash": "9kP1kWFJtbW2fFmGY2fs1kn0Rx1ARpf3Mz+QrLhagYUp6RaMN5Imus5YiOQWCTe8ZkQqktt8PsKUDWyvj8SiYQ==",
"dependencies": {
"Avalonia": "11.3.22",
"Avalonia.Angle.Windows.Natives": "2.1.25547.20250602"
}
},
"Avalonia.X11": {
"type": "Transitive",
"resolved": "11.3.22",
"contentHash": "w4OZxdAPAjGDKpBrU5kbiXzbbQT/ZcdGTMareDODdyIPFvd4AX+5iPAv08Ih87evHOXAf04dpvJyAEPgw1kMFg==",
"dependencies": {
"Avalonia": "11.3.22",
"Avalonia.FreeDesktop": "11.3.22",
"Avalonia.Skia": "11.3.22"
}
},
"DiffEngine": {
"type": "Transitive",
"resolved": "11.3.0",
"contentHash": "k0ZgZqd09jLZQjR8FyQbSQE86Q7QZnjEzq1LPHtj1R2AoWO8sjV5x+jlSisL7NZAbUOI4y+7Bog8gkr9WIRBGw==",
"dependencies": {
"EmptyFiles": "4.4.0",
"System.Management": "6.0.1"
}
},
"EmptyFiles": {
"type": "Transitive",
"resolved": "4.4.0",
"contentHash": "gwJEfIGS7FhykvtZoscwXj/XwW+mJY6UbAZk+qtLKFUGWC95kfKXnj8VkxsZQnWBxJemM/q664rGLN5nf+OHZw=="
},
"HarfBuzzSharp": {
"type": "Transitive",
"resolved": "8.3.1.1",
"contentHash": "tLZN66oe/uiRPTZfrCU4i8ScVGwqHNh5MHrXj0yVf4l7Mz0FhTGnQ71RGySROTmdognAs0JtluHkL41pIabWuQ==",
"dependencies": {
"HarfBuzzSharp.NativeAssets.Win32": "8.3.1.1",
"HarfBuzzSharp.NativeAssets.macOS": "8.3.1.1"
}
},
"HarfBuzzSharp.NativeAssets.Linux": {
"type": "Transitive",
"resolved": "8.3.1.1",
"contentHash": "3EZ1mpIiKWRLL5hUYA82ZHteeDIVaEA/Z0rA/wU6tjx6crcAkJnBPwDXZugBSfo8+J3EznvRJf49uMsqYfKrHg=="
},
"HarfBuzzSharp.NativeAssets.macOS": {
"type": "Transitive",
"resolved": "8.3.1.1",
"contentHash": "jbtCsgftcaFLCA13tVKo5iWdElJScrulLTKJre36O4YQTIlwDtPPqhRZNk+Y0vv4D1gxbscasGRucUDfS44ofQ=="
},
"HarfBuzzSharp.NativeAssets.WebAssembly": {
"type": "Transitive",
"resolved": "8.3.1.1",
"contentHash": "loJweK2u/mH/3C2zBa0ggJlITIszOkK64HLAZB7FUT670dTg965whLFYHDQo69NmC4+d9UN0icLC9VHidXaVCA=="
},
"HarfBuzzSharp.NativeAssets.Win32": {
"type": "Transitive",
"resolved": "8.3.1.1",
"contentHash": "UsJtQsfAJoFDZrXc4hCUfRPMqccfKZ0iumJ/upcUjz/cmsTgVFGNEL5yaJWmkqsuFYdMWbj/En5/kS4PFl9hBA=="
},
"Humanizer.Core": {
"type": "Transitive",
"resolved": "2.14.1",
"contentHash": "lQKvtaTDOXnoVJ20ibTuSIOf2i0uO0MPbDhd1jm238I+U/2ZnRENj0cktKZhtchBMtCUSRQ5v4xBCUbKNmyVMw=="
},
"MicroCom.Runtime": {
"type": "Transitive",
"resolved": "0.11.0",
"contentHash": "MEnrZ3UIiH40hjzMDsxrTyi8dtqB5ziv3iBeeU4bXsL/7NLSal9F1lZKpK+tfBRnUoDSdtcW3KufE4yhATOMCA=="
},
"Microsoft.Build.Framework": {
"type": "Transitive",
"resolved": "17.11.48",
"contentHash": "C3WIMt2wBl4++NX3jSEpTq5KXBhvAV154R4JrYHkfy9JSBcXWiL0mkgpspk5xSdOj+fS/uz7zluIy6bMM1fkkQ=="
},
"Microsoft.CodeAnalysis.Analyzers": {
"type": "Transitive",
"resolved": "5.9.0-1.26328.17",
"contentHash": "HP9NNk8ZjOSI2hgOyXnQg+kv7/X837Vr2nAlXiGAtqtYnYKjRRa1UmQFr8KFs5ynGYKqfbb8zB9APoWjiAGdMg=="
},
"Microsoft.CodeAnalysis.Common": {
"type": "Transitive",
"resolved": "5.9.0",
"contentHash": "IYaIaUWdIx539AReKZOBEqTskFusZfCh/wFSPilDvCn5Say8MegLw2LONcSIcVy+v3Gzv53qYBspgvBGSErfbQ==",
"dependencies": {
"Microsoft.CodeAnalysis.Analyzers": "5.9.0-1.26328.17"
}
},
"Microsoft.CodeAnalysis.CSharp": {
"type": "Transitive",
"resolved": "5.9.0",
"contentHash": "7JGDA0UT1+h7k9ZcA3rF4eFC8+QPq1xyYaXxag4p8r/zzPurEJxvdi7aM+MRL/SfP7XADXpWF/pl/eUYXOq/ww==",
"dependencies": {
"Microsoft.CodeAnalysis.Analyzers": "5.9.0-1.26328.17",
"Microsoft.CodeAnalysis.Common": "[5.9.0]"
}
},
"Microsoft.CodeAnalysis.Workspaces.Common": {
"type": "Transitive",
"resolved": "5.9.0",
"contentHash": "1A6jz50NG4nOEW8tX5+h+MyHqjWL0mPGwrUdwu+OlTfyknLo0GfxSqj4zEks8uVUdHdo9v8Ir9dHxACf8iYNEA==",
"dependencies": {
"Humanizer.Core": "2.14.1",
"Microsoft.CodeAnalysis.Analyzers": "5.9.0-1.26328.17",
"Microsoft.CodeAnalysis.Common": "[5.9.0]",
"System.Composition": "10.0.1"
}
},
"Microsoft.CodeCoverage": {
"type": "Transitive",
"resolved": "17.14.1",
"contentHash": "pmTrhfFIoplzFVbhVwUquT+77CbGH+h4/3mBpdmIlYtBi9nAB+kKI6dN3A/nV4DFi3wLLx/BlHIPK+MkbQ6Tpg=="
},
"Microsoft.Extensions.Configuration": {
"type": "Transitive",
"resolved": "11.0.0-rc.3.26522.148",
"contentHash": "eKYZzPkX3fQy+NBmU6Jn6i4K43cF7VR0EvEbe2xd+92LSjuW9KeLOG4IK8c1z6eZ0QhAp7t1m94EYrbFtCn6pQ=="
},
"Microsoft.Extensions.Configuration.Binder": {
"type": "Transitive",
"resolved": "11.0.0-rc.3.26522.148",
"contentHash": "/vkvZf2C7HOlKcxV7LzTnJ0S7kShfZbt+9wDvJpuqEV83ijZOSUf/npwEDFsjpw97EpX/0p0wxpjTUhgJJpk7w==",
"dependencies": {
"Microsoft.Extensions.Configuration": "11.0.0-rc.3.26522.148"
}
},
"Microsoft.Extensions.Configuration.FileExtensions": {
"type": "Transitive",
"resolved": "11.0.0-rc.3.26522.148",
"contentHash": "6YVPdNAEGVHh3LIapJ0WRtTmZjrDOaEcWpcX/Tr9Wd3p8rHJfqJ1uTKEwKpYf7NAgN24Ovh7RlX07ygdfz9TEw==",
"dependencies": {
"Microsoft.Extensions.Configuration": "11.0.0-rc.3.26522.148",
"Microsoft.Extensions.FileProviders.Physical": "11.0.0-rc.3.26522.148"
}
},
"Microsoft.Extensions.DependencyInjection": {
"type": "Transitive",
"resolved": "10.0.1",
"contentHash": "zerXV0GAR9LCSXoSIApbWn+Dq1/T+6vbXMHGduq1LoVQRHT0BXsGQEau0jeLUBUcsoF/NaUT8ADPu8b+eNcIyg=="
},
"Microsoft.Extensions.FileProviders.Physical": {
"type": "Transitive",
"resolved": "11.0.0-rc.3.26522.148",
"contentHash": "J0HtYwiJ7NFE3WMxKiJnixzYWPZmDGpbq+r6lWzzAF8MxznwW8DgCla6f1Sa76NkRuTFLhelOSA5Y4GIdR0xFQ==",
"dependencies": {
"Microsoft.Extensions.FileSystemGlobbing": "11.0.0-rc.3.26522.148"
}
},
"Microsoft.Extensions.FileSystemGlobbing": {
"type": "Transitive",
"resolved": "11.0.0-rc.3.26522.148",
"contentHash": "orWLCLpdv+XAyUA7zrrhAUZEHjoUkg4DeyxJIEs9xbg/FnuYhbRpL6msSdH+5WEvrw0TKjP4U0AYIlZx2WqXig=="
},
"Microsoft.Extensions.Logging": {
"type": "Transitive",
"resolved": "10.0.1",
"contentHash": "9ItMpMLFZFJFqCuHLLbR3LiA4ahA8dMtYuXpXl2YamSDWZhYS9BruPprkftY0tYi2bQ0slNrixdFm+4kpz1g5w==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection": "10.0.1"
}
},
"Microsoft.TestPlatform.ObjectModel": {
"type": "Transitive",
"resolved": "17.14.1",
"contentHash": "xTP1W6Mi6SWmuxd3a+jj9G9UoC850WGwZUps1Wah9r1ZxgXhdJfj1QqDLJkFjHDCvN42qDL2Ps5KjQYWUU0zcQ=="
},
"Microsoft.TestPlatform.TestHost": {
"type": "Transitive",
"resolved": "17.14.1",
"contentHash": "d78LPzGKkJwsJXAQwsbJJ7LE7D1wB+rAyhHHAaODF+RDSQ0NgMjDFkSA1Djw18VrxO76GlKAjRUhl+H8NL8Z+Q==",
"dependencies": {
"Microsoft.TestPlatform.ObjectModel": "17.14.1",
"Newtonsoft.Json": "13.0.3"
}
},
"Microsoft.VisualStudio.SolutionPersistence": {
"type": "Transitive",
"resolved": "1.0.52",
"contentHash": "oNv2JtYXhpdJrX63nibx1JT3uCESOBQ1LAk7Dtz/sr0+laW0KRM6eKp4CZ3MHDR2siIkKsY8MmUkeP5DKkQQ5w=="
},
"Newtonsoft.Json": {
"type": "Transitive",
"resolved": "13.0.3",
"contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ=="
},
"SkiaSharp": {
"type": "Transitive",
"resolved": "2.88.9",
"contentHash": "3MD5VHjXXieSHCleRLuaTXmL2pD0mB7CcOB1x2kA1I4bhptf4e3R27iM93264ZYuAq6mkUyX5XbcxnZvMJYc1Q==",
"dependencies": {
"SkiaSharp.NativeAssets.Win32": "2.88.9",
"SkiaSharp.NativeAssets.macOS": "2.88.9"
}
},
"SkiaSharp.NativeAssets.Linux": {
"type": "Transitive",
"resolved": "2.88.9",
"contentHash": "cWSaJKVPWAaT/WIn9c8T5uT/l4ETwHxNJTkEOtNKjphNo8AW6TF9O32aRkxqw3l8GUdUo66Bu7EiqtFh/XG0Zg==",
"dependencies": {
"SkiaSharp": "2.88.9"
}
},
"SkiaSharp.NativeAssets.macOS": {
"type": "Transitive",
"resolved": "2.88.9",
"contentHash": "Nv5spmKc4505Ep7oUoJ5vp3KweFpeNqxpyGDWyeEPTX2uR6S6syXIm3gj75dM0YJz7NPvcix48mR5laqs8dPuA=="
},
"SkiaSharp.NativeAssets.WebAssembly": {
"type": "Transitive",
"resolved": "2.88.9",
"contentHash": "kt06RccBHSnAs2wDYdBSfsjIDbY3EpsOVqnlDgKdgvyuRA8ZFDaHRdWNx1VHjGgYzmnFCGiTJBnXFl5BqGwGnA=="
},
"SkiaSharp.NativeAssets.Win32": {
"type": "Transitive",
"resolved": "2.88.9",
"contentHash": "wb2kYgU7iy84nQLYZwMeJXixvK++GoIuECjU4ECaUKNuflyRlJKyiRhN1MAHswvlvzuvkrjRWlK0Za6+kYQK7w=="
},
"System.CodeDom": {
"type": "Transitive",
"resolved": "6.0.0",
"contentHash": "CPc6tWO1LAer3IzfZufDBRL+UZQcj5uS207NHALQzP84Vp/z6wF0Aa0YZImOQY8iStY0A2zI/e3ihKNPfUm8XA=="
},
"System.Composition": {
"type": "Transitive",
"resolved": "10.0.1",
"contentHash": "DJkqaWQfho/ReTzKcZD3zJJ6K4GcS154k+T0UCPMBNIOZ2U/lNpyiiWZ6Etw0onWyTH1K+yhICsdmwA5xy2aPQ==",
"dependencies": {
"System.Composition.AttributedModel": "10.0.1",
"System.Composition.Convention": "10.0.1",
"System.Composition.Hosting": "10.0.1",
"System.Composition.Runtime": "10.0.1",
"System.Composition.TypedParts": "10.0.1"
}
},
"System.Composition.AttributedModel": {
"type": "Transitive",
"resolved": "10.0.1",
"contentHash": "mRxYvpCVPAeuLEk0c0kxWJVjbW1/HUoxCgYotOj9eDeQiYcTDOMdCQApsTrHYMN3pHBA8WoF00KGolG632Etaw=="
},
"System.Composition.Convention": {
"type": "Transitive",
"resolved": "10.0.1",
"contentHash": "dYynUByfVBzYDheNPGxS8UN8AvG/4tXf/coSs1odHOyoh4etv1kad/FrLWLMq4f8NO49NV20Xu+0/y613woTUA==",
"dependencies": {
"System.Composition.AttributedModel": "10.0.1"
}
},
"System.Composition.Hosting": {
"type": "Transitive",
"resolved": "10.0.1",
"contentHash": "4UGmyBdKWEN1nkqspJlji/nV7XIVm6KGlOC2So0mtM/gKvaNgLz+tUkcbY+6Zpr7dr6ohX1S5yl0RLID5otRHw==",
"dependencies": {
"System.Composition.Runtime": "10.0.1"
}
},
"System.Composition.Runtime": {
"type": "Transitive",
"resolved": "10.0.1",
"contentHash": "TVfys1gnUIhmXuYfFzyez0fOkDyELe9UwlxYeVlq6FmqmWmt1ouF0OQJ+6ozkHbkaop7uBUaXw7Qb+/o0m+nMg=="
},
"System.Composition.TypedParts": {
"type": "Transitive",
"resolved": "10.0.1",
"contentHash": "koSfjkdQZfgQr3SyiSIBboDn+GiR0vZ3x9Uek9FJbXK0w5AiATV8KrnMEP8B8OAlO+Y3zQf0CPCNzwH+VIYDKg==",
"dependencies": {
"System.Composition.AttributedModel": "10.0.1",
"System.Composition.Hosting": "10.0.1",
"System.Composition.Runtime": "10.0.1"
}
},
"System.Management": {
"type": "Transitive",
"resolved": "6.0.1",
"contentHash": "10J1D0h/lioojphfJ4Fuh5ZUThT/xOVHdV9roGBittKKNP2PMjrvibEdbVTGZcPra1399Ja3tqIJLyQrc5Wmhg==",
"dependencies": {
"System.CodeDom": "6.0.0"
}
},
"Tmds.DBus.Protocol": {
"type": "Transitive",
"resolved": "0.21.3",
"contentHash": "hDwB8WsQoyALQKqIbwzS68UKdlnafDm4T/DkO/JrA/YIneP/rKv96SxYPVXeh3FP4i/SXfShrYftKLtciJAIlw=="
},
"xunit.abstractions": {
"type": "Transitive",
"resolved": "2.0.3",
"contentHash": "pot1I4YOxlWjIb5jmwvvQNbTrZ3lJQ+jUGkGjWE3hEFM0l5gOnBWS+H3qsex68s5cO52g+44vpGzhAt+42vwKg=="
},
"xunit.analyzers": {
"type": "Transitive",
"resolved": "1.18.0",
"contentHash": "OtFMHN8yqIcYP9wcVIgJrq01AfTxijjAqVDy/WeQVSyrDC1RzBWeQPztL49DN2syXRah8TYnfvk035s7L95EZQ=="
},
"xunit.assert": {
"type": "Transitive",
"resolved": "2.9.3",
"contentHash": "/Kq28fCE7MjOV42YLVRAJzRF0WmEqsmflm0cfpMjGtzQ2lR5mYVj1/i0Y8uDAOLczkL3/jArrwehfMD0YogMAA=="
},
"xunit.core": {
"type": "Transitive",
"resolved": "2.9.3",
"contentHash": "BiAEvqGvyme19wE0wTKdADH+NloYqikiU0mcnmiNyXaF9HyHmE6sr/3DC5vnBkgsWaE6yPyWszKSPSApWdRVeQ==",
"dependencies": {
"xunit.extensibility.core": "[2.9.3]",
"xunit.extensibility.execution": "[2.9.3]"
}
},
"xunit.extensibility.core": {
"type": "Transitive",
"resolved": "2.9.3",
"contentHash": "kf3si0YTn2a8J8eZNb+zFpwfoyvIrQ7ivNk5ZYA5yuYk1bEtMe4DxJ2CF/qsRgmEnDr7MnW1mxylBaHTZ4qErA==",
"dependencies": {
"xunit.abstractions": "2.0.3"
}
},
"xunit.extensibility.execution": {
"type": "Transitive",
"resolved": "2.9.3",
"contentHash": "yMb6vMESlSrE3Wfj7V6cjQ3S4TXdXpRqYeNEI3zsX31uTsGMJjEw6oD5F5u1cHnMptjhEECnmZSsPxB6ChZHDQ==",
"dependencies": {
"xunit.extensibility.core": "[2.9.3]"
}
},
"cfa": {
"type": "Project",
"dependencies": {
"Glob": "[1.1.9, )",
"Microsoft.CodeAnalysis.CSharp.Workspaces": "[5.9.0, )",
"Microsoft.CodeAnalysis.Workspaces.MSBuild": "[5.9.0, )",
"Microsoft.Extensions.Configuration.Json": "[11.0.0-rc.3.26522.148, )",
"Microsoft.Extensions.Options.ConfigurationExtensions": "[11.0.0-rc.3.26522.148, )"
}
},
"cfa.ui": {
"type": "Project",
"dependencies": {
"Avalonia": "[11.3.22, )",
"Avalonia.Desktop": "[11.3.22, )",
"Avalonia.Fonts.Inter": "[11.3.22, )",
"Avalonia.Themes.Fluent": "[11.3.22, )",
"Cfa": "[1.0.0, )",
"CommunityToolkit.Mvvm": "[8.4.0, )",
"NodifyAvalonia": "[6.6.0, )"
}
},
"Avalonia": {
"type": "CentralTransitive",
"requested": "[11.3.22, )",
"resolved": "11.3.22",
"contentHash": "61kKbPq/2W/S8PkjRYnDCSvH2fAvhC3eTrYFtwMUZVShP5M2xXII76kIQo8bcezxwFkHc/yoRP3xzae2tFZRsQ==",
"dependencies": {
"Avalonia.BuildServices": "11.3.2",
"Avalonia.Remote.Protocol": "11.3.22",
"MicroCom.Runtime": "0.11.0"
}
},
"Avalonia.Desktop": {
"type": "CentralTransitive",
"requested": "[11.3.22, )",
"resolved": "11.3.22",
"contentHash": "TXdIJ5WA473KqFd/b3EqnXUIgFDhPuOx2OkzG7Fm8EkCPF5JKkCwg+i7yq8IRsYKcQ4I8X/4HkUyCb8DcfgfYg==",
"dependencies": {
"Avalonia": "11.3.22",
"Avalonia.Native": "11.3.22",
"Avalonia.Skia": "11.3.22",
"Avalonia.Win32": "11.3.22",
"Avalonia.X11": "11.3.22"
}
},
"Avalonia.Fonts.Inter": {
"type": "CentralTransitive",
"requested": "[11.3.22, )",
"resolved": "11.3.22",
"contentHash": "oGIn2U71EWTcTQ+zQU0CuJ5b+QsXIvmN7kvn5VSszgU6baZ0/1hDsR3wwXsU7hDFnJD1G541wtPqaGlZtKnPhA==",
"dependencies": {
"Avalonia": "11.3.22"
}
},
"Avalonia.Themes.Fluent": {
"type": "CentralTransitive",
"requested": "[11.3.22, )",
"resolved": "11.3.22",
"contentHash": "tCui436MQuxeIXj/eA/Mq9neAgNTud0Jk22HDWSo05KT+vaH5oJa53xI8PxEi3uRSsWK2l9yoQciLKrzzIrqCw==",
"dependencies": {
"Avalonia": "11.3.22"
}
},
"CommunityToolkit.Mvvm": {
"type": "CentralTransitive",
"requested": "[8.4.0, )",
"resolved": "8.4.0",
"contentHash": "tqVU8yc/ADO9oiTRyTnwhFN68hCwvkliMierptWOudIAvWY1mWCh5VFh+guwHJmpMwfg0J0rY+yyd5Oy7ty9Uw=="
},
"Glob": {
"type": "CentralTransitive",
"requested": "[1.1.9, )",
"resolved": "1.1.9",
"contentHash": "AfK5+ECWYTP7G3AAdnU8IfVj+QpGjrh9GC2mpdcJzCvtQ4pnerAGwHsxJ9D4/RnhDUz2DSzd951O/lQjQby2Sw=="
},
"Microsoft.CodeAnalysis.CSharp.Workspaces": {
"type": "CentralTransitive",
"requested": "[5.9.0, )",
"resolved": "5.9.0",
"contentHash": "D2zqK/k16fto0yMz0hcXMTkzOxEwMDJyA1mu/KXF9Befwz4zub3MpHQD8FeRxJtVSSsC3dQFYBw7zu7r/pfO7g==",
"dependencies": {
"Humanizer.Core": "2.14.1",
"Microsoft.CodeAnalysis.Analyzers": "5.9.0-1.26328.17",
"Microsoft.CodeAnalysis.CSharp": "[5.9.0]",
"Microsoft.CodeAnalysis.Common": "[5.9.0]",
"Microsoft.CodeAnalysis.Workspaces.Common": "[5.9.0]",
"System.Composition": "10.0.1"
}
},
"Microsoft.CodeAnalysis.Workspaces.MSBuild": {
"type": "CentralTransitive",
"requested": "[5.9.0, )",
"resolved": "5.9.0",
"contentHash": "BBux6hhD4wXt4lYN2oaY+jV4PnIj5plyqohijgSZtxJQqsvsldPEdii2q9lGrJbNjKEALnldDHz1rmGKG0PeeA==",
"dependencies": {
"Humanizer.Core": "2.14.1",
"Microsoft.Build.Framework": "17.11.48",
"Microsoft.CodeAnalysis.Analyzers": "5.9.0-1.26328.17",
"Microsoft.CodeAnalysis.Workspaces.Common": "[5.9.0]",
"Microsoft.Extensions.DependencyInjection": "10.0.1",
"Microsoft.Extensions.Logging": "10.0.1",
"Microsoft.VisualStudio.SolutionPersistence": "1.0.52",
"System.Composition": "10.0.1"
}
},
"Microsoft.Extensions.Configuration.Json": {
"type": "CentralTransitive",
"requested": "[11.0.0-rc.3.26522.148, )",
"resolved": "11.0.0-rc.3.26522.148",
"contentHash": "HwU/0bRyQm050kBidTu3wG9PiMh0yjehZ8gtDjYLDkJ9voC88sRTaerGRS945flSIcwfg4UuU2QEZKORBzC2Iw==",
"dependencies": {
"Microsoft.Extensions.Configuration": "11.0.0-rc.3.26522.148",
"Microsoft.Extensions.Configuration.FileExtensions": "11.0.0-rc.3.26522.148"
}
},
"Microsoft.Extensions.Options.ConfigurationExtensions": {
"type": "CentralTransitive",
"requested": "[11.0.0-rc.3.26522.148, )",
"resolved": "11.0.0-rc.3.26522.148",
"contentHash": "lkISyAw1jD1Ga97B9VpXTpvXm1YLGfApCRx7LTLzfaBk5YbmiB51U58Uumv+ov66ivhUHl0WdQe6MGFdAhIXjw==",
"dependencies": {
"Microsoft.Extensions.Configuration.Binder": "11.0.0-rc.3.26522.148"
}
},
"NodifyAvalonia": {
"type": "CentralTransitive",
"requested": "[6.6.0, )",
"resolved": "6.6.0",
"contentHash": "brSHSolQsce961EhMFelICg7rTriYzM2HOOZj+falLhgjatPShltnUzaQqYvKVgeTQ7w+vwDB2Z27DZR0FgDpw==",
"dependencies": {
"Avalonia": "11.1.0"
}
}
}
}
}
\ No newline at end of file