Conditional Call Flow
A VS Code extension that draws a conditional call graph rooted at a function you pick:
if A calls B inside if (x == value), the arrow A → B is labelled x == value.
public void Checkout(Order o) {
if (o == null) return; OrderService.Checkout
if (o.Type == "VIP") _gateway.Pay(o); │ o != null && o.Type == "VIP" (dashed: via IPaymentGateway)
else InvoiceLater(o); ├──────────▶ StripeGateway.Pay
} ├──────────▶ CashGateway.Pay
│ o != null && o.Type != "VIP"
└──────────▶ OrderService.InvoiceLater
It also has a class flow view for answering "where does this controller go, and where do these objects come from?"
in apps wired with dependency injection:
DI · Program.cs ──Scoped as IMonitorService──▶ MonitorService
MonitorController ──service──▶ IMonitorService ──DI Scoped──▶ MonitorService ──repo──▶ MonitorRepository
└──inherits──▷ ServiceBase
C# (.NET) is the first supported language. The analyzer is language-pluggable (see Adding a language).
Requirements
- .NET SDK 8 or newer on the machine (
dotnet --list-sdks should print something). The analyzer runs on it and uses
its MSBuild to load your projects.
- For .NET Framework projects (old-style
.csproj, e.g. WinForms/ASP.NET on v4.x): Visual Studio or
Visual Studio Build Tools with the .NET desktop / web build workload.
- A folder containing a
.sln, .slnx or .csproj.
The first time a folder is opened the project is loaded in the background (a few seconds, shown in the status bar);
after that every graph expansion is fast.
What the analyzer understands (C#)
| Construct |
Edge label |
Nested if |
a > 0 && b == 1 |
else / else if chains |
o.Type != "VIP" && o.Type == "B2B" |
Guard clauses (if (x == null) return; / throw / continue / break) |
later calls get x != null |
switch statement (incl. several labels, patterns, when) |
o.Status == OrderStatus.Failed \|\| o.Status == OrderStatus.Refunded |
switch expression arms |
o.Status is OrderStatus.Paid |
Ternary ?:, &&, \|\|, ??, ?. |
branch-specific conditions |
foreach / for / while / do, catch, finally, lambdas, local functions |
context prefix, e.g. [foreach item in o.Items] |
| Calls through an interface (DI) |
resolved to each implementation, dashed purple edge |
| Calls to virtual / abstract members |
resolved to overrides, dotted orange edge |
new Foo() |
edge to the constructor |
Only code that lives in your solution is drawn. System.*, Microsoft.* and other NuGet/framework calls are dropped
automatically; add more with codeFlow.ignorePatterns (e.g. your logging wrapper).
Class flow
| Arrow |
Meaning |
Found from |
Derived ──▷ Base (grey, hollow arrow) |
inherits |
base list |
Class ┄┄▷ IInterface (purple dashed, hollow) |
implements |
base list |
Consumer ──param──▶ Dependency (blue) |
injected via constructor (incl. primary constructors, IEnumerable<T>, Lazy<T>, IOptions<T>) |
constructor parameters |
DI · Program.cs ┈Scoped as IFoo┈▶ Foo (green dotted) |
registered in the DI container |
AddSingleton / AddScoped / AddTransient / TryAdd* / AddHostedService / AddDbContext / AddHttpClient, generic or typeof(...) or factory sp => new Foo(...) |
IFoo ┄DI Scoped┄▶ Foo (green dashed) |
which implementation DI hands out for an interface |
AddScoped<IFoo, Foo>() |
Creator ──new in Start()──▶ Foo (orange) |
object created with new |
new Foo(...) / new() |
Nodes show the role of each type: controller, background service, db context, signalr hub, razor page,
form, interface, abstract class, record… A file that registers services (e.g. Program.cs) gets a
DI · Program.cs entry: open it to see every registered service, then follow each one to its dependencies.
Opening a class follows its dependencies, implementations and base classes automatically
(codeFlow.classInitialDepth, default 3). Who uses or creates a class is shown for every expanded class but not
followed, so the graph stays focused; right-click any node to expand it.
Using it
- Open a folder containing a
.sln, .slnx or .csproj.
- Click the Code Flow icon in the activity bar. The sidebar shows the active
.cs file in two folders:
Functions (methods, grouped by class) and Classes (types, plus the DI entry if the file registers services).
- Click a function for its call flow, or a class for its class flow. The graph opens beside the editor.
- In the graph:
- Right-click a node (or select it and press Expand +1 / +3) to load deeper calls. Nodes that are not expanded yet have a dashed border and a
+.
- Double-click a node to open the function.
- Click an edge to jump to the exact call site and see the full condition.
- Set as root re-centres the graph on the selected node. Direction switches between top-to-bottom and left-to-right.
You can also right-click in the editor and choose Show Call Flow for Function at Cursor or
Show Class Flow for Type at Cursor (in Program.cs outside any class, this opens the DI registrations).
Unsaved edits are pushed to the analyzer as you type, so the graph reflects the current buffer.
Use Reload Workspace (sidebar title bar) after adding projects or files.
Running locally (no Marketplace needed)
Requirements: Node.js 18+, .NET SDK 8+ (the analyzer targets net10.0 and rolls forward).
npm install
npm run build # builds the Roslyn daemon into dist/analyzer and compiles the TypeScript
Debug: open this folder in VS Code and press F5. Run Extension (sample C# project) opens
samples/SampleShop in an Extension Development Host so you can try it right away.
Install as a .vsix:
npm run package # creates conditional-call-flow-0.2.0.vsix
code --install-extension conditional-call-flow-0.2.0.vsix
The .vsix contains the compiled analyzer, so the target machine only needs the .NET runtime/SDK.
Settings
| Setting |
Default |
Meaning |
codeFlow.initialDepth |
2 |
Call levels loaded when a function is selected |
codeFlow.classInitialDepth |
3 |
Levels the class flow follows automatically |
codeFlow.csharp.solutionPath |
"" |
Force a specific .sln / .csproj (otherwise the top-most .sln, else every .csproj) |
codeFlow.csharp.dotnetPath |
dotnet |
dotnet executable used to start the analyzer |
codeFlow.ignorePatterns |
logging calls |
Regexes matched against Namespace.Type.Method |
codeFlow.layoutDirection |
TB |
TB or LR |
Analyzer logs (project load, MSBuild warnings, errors) go to the Code Flow output channel.
Architecture
┌────────────────────── VS Code extension (TypeScript) ──────────────────────┐
│ FunctionTreeProvider ──select fn──▶ GraphWebview (Cytoscape.js + dagre) │
│ │ ▲ expand / jump / reroot │
│ ▼ │ │
│ AdapterRegistry ──▶ ILanguageAdapter ─────┘ │
│ └─ CSharpDaemonAdapter │
└──────────────────────────────┬─────────────────────────────────────────────┘
│ newline-delimited JSON over stdin/stdout
┌──────────────────────────────▼─────────────────────────────────────────────┐
│ analyzer-csharp (long-running .NET process) │
│ WorkspaceAnalyzer: MSBuildWorkspace kept in memory, incremental updates │
│ FlowWalker: path conditions (if/else/guard/switch/?:/&&/||/?.) │
│ SymbolFinder: interface implementations and virtual overrides │
└────────────────────────────────────────────────────────────────────────────┘
The daemon is started once and keeps the workspace loaded, so after the first load each expansion takes tens to a few
hundred milliseconds. If it crashes it is restarted automatically on the next request.
Protocol (one JSON object per line):
Request action |
Payload |
Response data |
init |
paths: string[], ignorePatterns: string[] |
{ projects, documents } |
list_functions |
filePath |
FunctionNode[] |
get_calls |
symbolId |
CallEdge[] |
update_file |
filePath, text |
{ updated } |
Every response is { "id": <request id>, "ok": true, "data": ... } or { "id", "ok": false, "error" }.
Adding a language
- Implement
ILanguageAdapter (src/parsers/languageAdapter.ts): listFunctions, getOutgoingCalls, optionally updateFile.
It can run in-process (e.g. the TypeScript compiler API, tree-sitter) or talk to a daemon like the C# one.
- Register it in
src/extension.ts: registry.register(new MyAdapter(...)).
- Add its
onLanguage: activation event in package.json.
The sidebar and the graph pick the adapter by file extension, so nothing else changes.
Known limitations
- Static analysis: it shows every call that can happen and the syntactic condition guarding it. It does not evaluate values,
so conditions that depend on earlier assignments are shown as written.
- Calls made through delegates, events, reflection or DI container registration lookups are not resolved.
- Property getters/setters are not drawn as nodes; local functions and lambdas are folded into their containing method
(with an
[in lambda] / [in Foo()] prefix).
- Guard clauses are detected when the
if body always exits (return, throw, break, continue, goto).