366 lines
14 KiB
C#
366 lines
14 KiB
C#
using Microsoft.CodeAnalysis;
|
|
using Microsoft.CodeAnalysis.CSharp;
|
|
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
|
using ArtificersScrollwork.Sidecar.Models;
|
|
|
|
namespace ArtificersScrollwork.Sidecar.Parsing;
|
|
|
|
/// <summary>
|
|
/// Traces method call chains across ServUO scripts using Roslyn and produces a FlowNode tree.
|
|
/// Entry point: TraceMethod(className, methodName).
|
|
/// Max recursion depth: 8 levels.
|
|
/// </summary>
|
|
public class CallChainTracer
|
|
{
|
|
private const int MaxDepth = 8;
|
|
|
|
private readonly CSharpCompilation _compilation;
|
|
private readonly Dictionary<string, (ClassDeclarationSyntax Syntax, SemanticModel Model)> _classMap;
|
|
|
|
public CallChainTracer(CSharpCompilation compilation)
|
|
{
|
|
_compilation = compilation;
|
|
_classMap = BuildClassMap();
|
|
}
|
|
|
|
// ── Public entry point ────────────────────────────────────────────────────
|
|
|
|
public FlowNode TraceMethod(string className, string methodName)
|
|
{
|
|
if (!_classMap.TryGetValue(className, out var entry))
|
|
throw new KeyNotFoundException($"Class '{className}' not found");
|
|
|
|
var methodSyntax = entry.Syntax.Members
|
|
.OfType<MethodDeclarationSyntax>()
|
|
.FirstOrDefault(m => m.Identifier.Text == methodName)
|
|
?? throw new KeyNotFoundException($"Method '{methodName}' not found in '{className}'");
|
|
|
|
var rootNode = new FlowNode(
|
|
Id: NewId(),
|
|
Type: "method_call",
|
|
Label: $"{className}.{methodName}",
|
|
Children: TraceMethodBody(methodSyntax, entry.Model, depth: 0),
|
|
FakeInputKey: null,
|
|
ResolvedGump: null,
|
|
AssetRef: null
|
|
);
|
|
|
|
return rootNode;
|
|
}
|
|
|
|
// ── Internal traversal ────────────────────────────────────────────────────
|
|
|
|
private List<FlowNode> TraceMethodBody(
|
|
MethodDeclarationSyntax method,
|
|
SemanticModel model,
|
|
int depth)
|
|
{
|
|
if (depth >= MaxDepth) return [];
|
|
if (method.Body is null && method.ExpressionBody is null) return [];
|
|
|
|
SyntaxNode body = (SyntaxNode?)method.Body ?? method.ExpressionBody!;
|
|
return TraceStatements(body.ChildNodes(), model, depth);
|
|
}
|
|
|
|
private List<FlowNode> TraceStatements(
|
|
IEnumerable<SyntaxNode> nodes,
|
|
SemanticModel model,
|
|
int depth)
|
|
{
|
|
var result = new List<FlowNode>();
|
|
foreach (var node in nodes)
|
|
{
|
|
var children = TraceNode(node, model, depth);
|
|
result.AddRange(children);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
private List<FlowNode> TraceNode(SyntaxNode node, SemanticModel model, int depth)
|
|
{
|
|
return node switch
|
|
{
|
|
IfStatementSyntax ifStmt => [TraceIf(ifStmt, model, depth)],
|
|
SwitchStatementSyntax sw => [TraceSwitch(sw, model, depth)],
|
|
ReturnStatementSyntax ret => [TraceReturn(ret, model, depth)],
|
|
ExpressionStatementSyntax expr => TraceExpressionStatement(expr, model, depth),
|
|
LocalDeclarationStatementSyntax local => TraceLocal(local, model, depth),
|
|
ForEachStatementSyntax forEach => [TraceForEach(forEach, model, depth)],
|
|
ForStatementSyntax forStmt => [TraceFor(forStmt, model, depth)],
|
|
BlockSyntax block => TraceStatements(block.Statements, model, depth),
|
|
_ => [],
|
|
};
|
|
}
|
|
|
|
// ── Statement handlers ────────────────────────────────────────────────────
|
|
|
|
private FlowNode TraceIf(IfStatementSyntax ifStmt, SemanticModel model, int depth)
|
|
{
|
|
var conditionText = ifStmt.Condition.ToString().Trim();
|
|
var fakeKey = ExtractFakeInputKey(ifStmt.Condition);
|
|
|
|
var trueChildren = TraceNode(ifStmt.Statement, model, depth + 1);
|
|
var falseChildren = ifStmt.Else is not null
|
|
? TraceNode(ifStmt.Else.Statement, model, depth + 1)
|
|
: [];
|
|
|
|
var branches = new List<FlowNode>();
|
|
if (trueChildren.Count > 0)
|
|
branches.Add(new FlowNode(NewId(), "branch_true", "true", trueChildren));
|
|
if (falseChildren.Count > 0)
|
|
branches.Add(new FlowNode(NewId(), "branch_false", "false", falseChildren));
|
|
|
|
return new FlowNode(
|
|
Id: NewId(),
|
|
Type: "condition",
|
|
Label: $"if ({Truncate(conditionText, 60)})",
|
|
Children: branches,
|
|
FakeInputKey: fakeKey,
|
|
ResolvedGump: null,
|
|
AssetRef: null
|
|
);
|
|
}
|
|
|
|
private FlowNode TraceSwitch(SwitchStatementSyntax sw, SemanticModel model, int depth)
|
|
{
|
|
var conditionText = sw.Expression.ToString().Trim();
|
|
var branches = new List<FlowNode>();
|
|
|
|
foreach (var section in sw.Sections)
|
|
{
|
|
var label = string.Join(", ", section.Labels.Select(l => l.ToString().Trim()));
|
|
var sectionChildren = TraceStatements(section.Statements, model, depth + 1);
|
|
if (sectionChildren.Count > 0)
|
|
branches.Add(new FlowNode(NewId(), "branch_case", label, sectionChildren));
|
|
}
|
|
|
|
return new FlowNode(
|
|
Id: NewId(),
|
|
Type: "condition",
|
|
Label: $"switch ({Truncate(conditionText, 60)})",
|
|
Children: branches,
|
|
FakeInputKey: ExtractFakeInputKey(sw.Expression),
|
|
ResolvedGump: null,
|
|
AssetRef: null
|
|
);
|
|
}
|
|
|
|
private FlowNode TraceReturn(ReturnStatementSyntax ret, SemanticModel model, int depth)
|
|
{
|
|
var label = ret.Expression is not null
|
|
? $"return {Truncate(ret.Expression.ToString(), 50)}"
|
|
: "return";
|
|
|
|
return new FlowNode(NewId(), "return", label, []);
|
|
}
|
|
|
|
private List<FlowNode> TraceExpressionStatement(
|
|
ExpressionStatementSyntax expr,
|
|
SemanticModel model,
|
|
int depth)
|
|
{
|
|
return TraceExpression(expr.Expression, model, depth);
|
|
}
|
|
|
|
private List<FlowNode> TraceLocal(
|
|
LocalDeclarationStatementSyntax local,
|
|
SemanticModel model,
|
|
int depth)
|
|
{
|
|
// Only interesting if the initializer contains a method call
|
|
var nodes = new List<FlowNode>();
|
|
foreach (var variable in local.Declaration.Variables)
|
|
{
|
|
if (variable.Initializer?.Value is InvocationExpressionSyntax invoc)
|
|
nodes.AddRange(TraceInvocation(invoc, model, depth, label: variable.Identifier.Text + " = …"));
|
|
}
|
|
return nodes;
|
|
}
|
|
|
|
private FlowNode TraceForEach(ForEachStatementSyntax forEach, SemanticModel model, int depth)
|
|
{
|
|
var label = $"foreach ({forEach.Type} {forEach.Identifier} in {Truncate(forEach.Expression.ToString(), 40)})";
|
|
var children = TraceNode(forEach.Statement, model, depth + 1);
|
|
return new FlowNode(NewId(), "condition", label, children, FakeInputKey: null);
|
|
}
|
|
|
|
private FlowNode TraceFor(ForStatementSyntax forStmt, SemanticModel model, int depth)
|
|
{
|
|
var label = $"for (…; {Truncate(forStmt.Condition?.ToString() ?? "", 40)}; …)";
|
|
var children = TraceNode(forStmt.Statement, model, depth + 1);
|
|
return new FlowNode(NewId(), "condition", label, children, FakeInputKey: null);
|
|
}
|
|
|
|
// ── Expression handlers ───────────────────────────────────────────────────
|
|
|
|
private List<FlowNode> TraceExpression(ExpressionSyntax expr, SemanticModel model, int depth)
|
|
{
|
|
return expr switch
|
|
{
|
|
InvocationExpressionSyntax invoc => TraceInvocation(invoc, model, depth),
|
|
AssignmentExpressionSyntax assign => TraceAssignment(assign, model, depth),
|
|
ConditionalExpressionSyntax ternary => TraceExpressionList([ternary.WhenTrue, ternary.WhenFalse], model, depth),
|
|
AwaitExpressionSyntax awaitExpr => TraceExpression(awaitExpr.Expression, model, depth),
|
|
_ => [],
|
|
};
|
|
}
|
|
|
|
private List<FlowNode> TraceExpressionList(IEnumerable<ExpressionSyntax> exprs, SemanticModel model, int depth)
|
|
{
|
|
return exprs.SelectMany(e => TraceExpression(e, model, depth)).ToList();
|
|
}
|
|
|
|
private List<FlowNode> TraceAssignment(AssignmentExpressionSyntax assign, SemanticModel model, int depth)
|
|
{
|
|
return TraceExpression(assign.Right, model, depth);
|
|
}
|
|
|
|
private List<FlowNode> TraceInvocation(
|
|
InvocationExpressionSyntax invoc,
|
|
SemanticModel model,
|
|
int depth,
|
|
string? label = null)
|
|
{
|
|
var invocStr = invoc.ToString();
|
|
|
|
// ── SendGump check ────────────────────────────────────────────────────
|
|
if (IsSendGump(invocStr))
|
|
{
|
|
var gumpClass = ExtractGumpClass(invoc);
|
|
return [new FlowNode(
|
|
Id: NewId(),
|
|
Type: "gump_send",
|
|
Label: label ?? invocStr.Truncate(80),
|
|
Children: [],
|
|
FakeInputKey: null,
|
|
ResolvedGump: gumpClass,
|
|
AssetRef: null
|
|
)];
|
|
}
|
|
|
|
// ── Property / member access (not a real call chain node) ────────────
|
|
if (IsPropertyAccess(invoc))
|
|
{
|
|
return [new FlowNode(NewId(), "property_access", label ?? Truncate(invocStr, 80), [])];
|
|
}
|
|
|
|
// ── Try to resolve and recurse into the called method ─────────────────
|
|
if (depth < MaxDepth)
|
|
{
|
|
var calledMethod = TryResolveMethod(invoc, model);
|
|
if (calledMethod is not null)
|
|
{
|
|
var resolvedLabel = label ?? BuildCallLabel(invoc);
|
|
var children = TraceMethodBody(calledMethod.Syntax, calledMethod.Model, depth + 1);
|
|
if (children.Count > 0)
|
|
{
|
|
return [new FlowNode(NewId(), "method_call", resolvedLabel, children)];
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Leaf call node ────────────────────────────────────────────────────
|
|
var callLabel = label ?? BuildCallLabel(invoc);
|
|
return [new FlowNode(NewId(), "method_call", callLabel, [])];
|
|
}
|
|
|
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
|
|
|
private Dictionary<string, (ClassDeclarationSyntax, SemanticModel)> BuildClassMap()
|
|
{
|
|
var map = new Dictionary<string, (ClassDeclarationSyntax, SemanticModel)>(
|
|
StringComparer.OrdinalIgnoreCase);
|
|
|
|
foreach (var tree in _compilation.SyntaxTrees)
|
|
{
|
|
var model = _compilation.GetSemanticModel(tree);
|
|
foreach (var cls in tree.GetRoot().DescendantNodes().OfType<ClassDeclarationSyntax>())
|
|
{
|
|
var symbol = model.GetDeclaredSymbol(cls);
|
|
if (symbol is not null && !map.ContainsKey(symbol.Name))
|
|
map[symbol.Name] = (cls, model);
|
|
}
|
|
}
|
|
|
|
return map;
|
|
}
|
|
|
|
private record ResolvedMethod(MethodDeclarationSyntax Syntax, SemanticModel Model);
|
|
|
|
private ResolvedMethod? TryResolveMethod(InvocationExpressionSyntax invoc, SemanticModel model)
|
|
{
|
|
try
|
|
{
|
|
var symbolInfo = model.GetSymbolInfo(invoc);
|
|
var symbol = symbolInfo.Symbol as IMethodSymbol;
|
|
if (symbol is null) return null;
|
|
|
|
var containingClass = symbol.ContainingType?.Name;
|
|
if (containingClass is null) return null;
|
|
|
|
if (!_classMap.TryGetValue(containingClass, out var entry)) return null;
|
|
|
|
var methodSyntax = entry.Syntax.Members
|
|
.OfType<MethodDeclarationSyntax>()
|
|
.FirstOrDefault(m => m.Identifier.Text == symbol.Name);
|
|
|
|
return methodSyntax is not null ? new ResolvedMethod(methodSyntax, entry.Model) : null;
|
|
}
|
|
catch
|
|
{
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private static bool IsSendGump(string invocStr) =>
|
|
invocStr.Contains("SendGump", StringComparison.OrdinalIgnoreCase);
|
|
|
|
private static bool IsPropertyAccess(InvocationExpressionSyntax invoc)
|
|
{
|
|
var memberAccess = invoc.Expression as MemberAccessExpressionSyntax;
|
|
return memberAccess?.Name.Identifier.Text is "ToString" or "GetType";
|
|
}
|
|
|
|
private static string? ExtractGumpClass(InvocationExpressionSyntax invoc)
|
|
{
|
|
var newExpr = invoc.DescendantNodes().OfType<ObjectCreationExpressionSyntax>().FirstOrDefault();
|
|
return newExpr?.Type.ToString();
|
|
}
|
|
|
|
private static string? ExtractFakeInputKey(ExpressionSyntax expr)
|
|
{
|
|
// If the condition references a simple identifier or member access, use it as the key
|
|
return expr switch
|
|
{
|
|
IdentifierNameSyntax id => id.Identifier.Text,
|
|
MemberAccessExpressionSyntax ma => ma.Name.Identifier.Text,
|
|
BinaryExpressionSyntax bin => ExtractFakeInputKey(bin.Left),
|
|
PrefixUnaryExpressionSyntax pre => ExtractFakeInputKey(pre.Operand),
|
|
_ => null,
|
|
};
|
|
}
|
|
|
|
private static string BuildCallLabel(InvocationExpressionSyntax invoc)
|
|
{
|
|
var name = invoc.Expression switch
|
|
{
|
|
MemberAccessExpressionSyntax ma => $"{ma.Expression}.{ma.Name}",
|
|
IdentifierNameSyntax id => id.Identifier.Text,
|
|
_ => invoc.Expression.ToString(),
|
|
};
|
|
return Truncate(name, 80);
|
|
}
|
|
|
|
private static string Truncate(string s, int max) =>
|
|
s.Length <= max ? s : s[..max] + "…";
|
|
|
|
private static int _counter;
|
|
private static string NewId() => $"n{System.Threading.Interlocked.Increment(ref _counter)}";
|
|
}
|
|
|
|
internal static class StringExtensions
|
|
{
|
|
public static string Truncate(this string s, int max) =>
|
|
s.Length <= max ? s : s[..max] + "…";
|
|
}
|