Initial commit

This commit is contained in:
2026-06-05 20:53:53 -05:00
commit f9a59e9a66
99 changed files with 15897 additions and 0 deletions

View File

@@ -0,0 +1,365 @@
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] + "…";
}