Skip to content

Commit 0eeb218

Browse files
authored
CallHierarchyOutgoingCalls (#1418)
* Add Call Hierarchy Outgoing Calls functionality and tests - Implemented CallHierarchyOutgoingCalls in AdaptiveFSharpLspServer. - Added tests for outgoing calls in CallHierarchyTests. - Created example files for outgoing calls scenarios. - Updated project files to include new tests and configurations. * Enhance Call Hierarchy functionality and tests by refining symbol use filtering and updating test cases for accuracy * Add tests for recursive functions in Call Hierarchy, covering simple recursion, mutual recursion, tree traversal, and async patterns * Add nested call hierarchy examples and tests for deep navigation scenarios * Fix line numbers and comments in outgoing call tests for accuracy * Remove obsolete test files for Call Hierarchy and Outgoing Calls * Remove commented-out code and simplify string interpolation in AdaptiveFSharpLspServer * Add call hierarchy helpers and tests for outgoing calls in local functions, operators, and properties * Add nested call hierarchy tests for parallel and multi-module workflows in NestedExample2 * Implement SourceLink integration for external symbols in Call Hierarchy, enhancing resolution of local file paths and adding related tests. * Add semaphore for concurrent downloads in SourceLink to prevent race conditions * Rename semaphore lock variable for clarity in downloadFileToTempDir function * Enhance outgoing call tests to validate SourceLink file existence and external build paths * Fix formatting in outgoingTests to improve readability of path checks * Refactor outgoingTests to improve handling of SourceLink behavior and clarify file existence checks
1 parent 7d1af9c commit 0eeb218

File tree

20 files changed

+1496
-18
lines changed

20 files changed

+1496
-18
lines changed

.github/copilot-instructions.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -242,4 +242,18 @@ This project uses **Paket** for dependency management instead of NuGet directly:
242242
### Related Tools
243243
- [FSharpLint](https://github.com/fsprojects/FSharpLint/) - Static analysis tool
244244
- [Paket](https://fsprojects.github.io/Paket/) - Dependency management
245-
- [FAKE](https://fake.build/) - Build automation (used for scaffolding)
245+
- [FAKE](https://fake.build/) - Build automation (used for scaffolding)
246+
247+
248+
### MCP Tools
249+
250+
> [!IMPORTANT]
251+
252+
You have access to a long-term memory system via the Model Context Protocol (MCP) at the endpoint `memorizer`. Use the following tools:
253+
- `store`: Store a new memory. Parameters: `type`, `content` (markdown), `source`, `tags`, `confidence`, `relatedTo` (optional, memory ID), `relationshipType` (optional).
254+
- `search`: Search for similar memories. Parameters: `query`, `limit`, `minSimilarity`, `filterTags`.
255+
- `get`: Retrieve a memory by ID. Parameter: `id`.
256+
- `getMany`: Retrieve multiple memories by their IDs. Parameter: `ids` (list of IDs).
257+
- `delete`: Delete a memory by ID. Parameter: `id`.
258+
- `createRelationship`: Create a relationship between two memories. Parameters: `fromId`, `toId`, `type`.
259+
Use these tools to remember, recall, relate, and manage information as needed to assist the user. You can also manually retrieve or relate memories by their IDs when necessary.

.vscode/mcp.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"servers": {
3+
"ionide-memorizer": {
4+
"url": "http://localhost:5001/sse",
5+
}
6+
},
7+
"inputs": []
8+
}

AGENTS.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,7 @@ This project uses **Paket** for dependency management instead of NuGet directly:
159159
4. Include edge cases and error conditions
160160
5. For code fixes: Run focused tests with `dotnet run -f net8.0 --project ./test/FsAutoComplete.Tests.Lsp/FsAutoComplete.Tests.Lsp.fsproj`
161161
6. Remove focused test markers before submitting PRs (they cause CI failures)
162+
7. Do not delete tests without permission.
162163

163164
### Test Data
164165
- Sample F# projects in `TestCases/` directories
@@ -242,4 +243,4 @@ This project uses **Paket** for dependency management instead of NuGet directly:
242243
### Related Tools
243244
- [FSharpLint](https://github.com/fsprojects/FSharpLint/) - Static analysis tool
244245
- [Paket](https://fsprojects.github.io/Paket/) - Dependency management
245-
- [FAKE](https://fake.build/) - Build automation (used for scaffolding)
246+
- [FAKE](https://fake.build/) - Build automation (used for scaffolding)

src/FsAutoComplete.Core/Sourcelink.fs

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ open FsAutoComplete.Logging
99
open FSharp.UMX
1010
open FsAutoComplete.Utils
1111
open Ionide.ProjInfo.ProjectSystem
12+
open IcedTasks
1213

1314
let logger = LogProvider.getLoggerByName "FsAutoComplete.Sourcelink"
1415

@@ -217,6 +218,8 @@ let private tryGetUrlForDocument (json: SourceLinkJson) (document: Document) =
217218
else
218219
tryGetUrlWithExactMatch path url document)
219220

221+
let sourceLinkSemaphore = new System.Threading.SemaphoreSlim(1, 1)
222+
220223
let private downloadFileToTempDir
221224
(url: string<Url>)
222225
(repoPathFragment: string<NormalizedRepoPathSegment>)
@@ -228,18 +231,28 @@ let private downloadFileToTempDir
228231
let tempDir = Path.GetDirectoryName tempFile
229232
Directory.CreateDirectory tempDir |> ignore
230233

231-
async {
232-
logger.info (
233-
Log.setMessage "Getting file from {url} for document {repoPath}"
234-
>> Log.addContextDestructured "url" url
235-
>> Log.addContextDestructured "repoPath" repoPathFragment
236-
)
234+
asyncEx {
235+
use! _lock = sourceLinkSemaphore.LockAsync()
236+
// Check if file already exists (cached from previous download)
237+
if File.Exists tempFile then
238+
logger.info (
239+
Log.setMessage "Using cached SourceLink file for document {repoPath}"
240+
>> Log.addContextDestructured "repoPath" repoPathFragment
241+
)
242+
243+
return UMX.tag<LocalPath> tempFile
244+
else
245+
logger.info (
246+
Log.setMessage "Getting file from {url} for document {repoPath}"
247+
>> Log.addContextDestructured "url" url
248+
>> Log.addContextDestructured "repoPath" repoPathFragment
249+
)
237250

238-
let! response = httpClient.GetStreamAsync(UMX.untag url) |> Async.AwaitTask
251+
let! response = httpClient.GetStreamAsync(UMX.untag url) |> Async.AwaitTask
239252

240-
use fileStream = File.OpenWrite tempFile
241-
do! response.CopyToAsync fileStream |> Async.AwaitTask
242-
return UMX.tag<LocalPath> tempFile
253+
use fileStream = File.OpenWrite tempFile
254+
do! response.CopyToAsync fileStream |> Async.AwaitTask
255+
return UMX.tag<LocalPath> tempFile
243256
}
244257

245258
type Errors =

src/FsAutoComplete/LspServers/AdaptiveFSharpLspServer.fs

Lines changed: 257 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -44,12 +44,129 @@ open System.Threading.Tasks
4444
open FsAutoComplete.FCSPatches
4545
open Helpers
4646
open System.Runtime.ExceptionServices
47+
open FSharp.Compiler.CodeAnalysis
4748

4849
module ArrayHelpers =
4950
let (|EmptyArray|NonEmptyArray|) (a: 'a array) = if a.Length = 0 then EmptyArray else NonEmptyArray a
5051

5152
open ArrayHelpers
5253

54+
module CallHierarchyHelpers =
55+
/// Determines if a symbol represents a callable (function, method, constructor, or applicable entity)
56+
let isCallableSymbol (symbol: FSharpSymbol) =
57+
match symbol with
58+
| :? FSharpMemberOrFunctionOrValue as mfv ->
59+
mfv.IsFunction
60+
|| mfv.IsMethod
61+
|| mfv.IsConstructor
62+
|| (mfv.IsProperty
63+
&& not (mfv.LogicalName.Contains("get_") || mfv.LogicalName.Contains("set_")))
64+
| :? FSharpEntity as ent -> ent.IsClass || ent.IsFSharpRecord || ent.IsFSharpUnion
65+
| _ -> false
66+
67+
/// Filters symbol uses to those within a binding range that represent outgoing calls
68+
let getOutgoingCallsInBinding
69+
(bindingRange: Range)
70+
(pos: FSharp.Compiler.Text.Position)
71+
(allSymbolUses: FSharpSymbolUse seq)
72+
=
73+
allSymbolUses
74+
|> Seq.filter (fun su ->
75+
Range.rangeContainsRange bindingRange su.Range
76+
&& not su.IsFromDefinition
77+
&& su.Range.Start <> pos
78+
&& isCallableSymbol su.Symbol)
79+
|> Seq.toArray
80+
81+
/// Gets the appropriate SymbolKind for a given FSharpSymbol
82+
let getSymbolKind (symbol: FSharpSymbol) =
83+
match symbol with
84+
| :? FSharpMemberOrFunctionOrValue as mfv ->
85+
if mfv.IsConstructor then SymbolKind.Constructor
86+
elif mfv.IsProperty then SymbolKind.Property
87+
elif mfv.IsMethod then SymbolKind.Method
88+
elif mfv.IsEvent then SymbolKind.Event
89+
else SymbolKind.Function
90+
| :? FSharpEntity as ent ->
91+
if ent.IsClass then SymbolKind.Class
92+
elif ent.IsInterface then SymbolKind.Interface
93+
elif ent.IsFSharpModule then SymbolKind.Module
94+
elif ent.IsEnum then SymbolKind.Enum
95+
elif ent.IsValueType then SymbolKind.Struct
96+
else SymbolKind.Object
97+
| _ -> SymbolKind.Function
98+
99+
/// Extracts the normalized file path segment from a declaration location, handling OS differences
100+
let private getSourceFileSegment (loc: FSharp.Compiler.Text.Range) : string<NormalizedRepoPathSegment> =
101+
if Ionide.ProjInfo.ProjectSystem.Environment.isWindows then
102+
UMX.tag<NormalizedRepoPathSegment> loc.FileName
103+
else
104+
UMX.tag<NormalizedRepoPathSegment> (System.IO.Path.GetFileName loc.FileName)
105+
106+
/// Result of resolving an external symbol's source location
107+
[<RequireQualifiedAccess>]
108+
type ExternalSymbolLocationResult =
109+
/// The file exists locally (either in workspace or already downloaded via SourceLink)
110+
| LocalFile of filePath: string<LocalPath> * range: FSharp.Compiler.Text.Range
111+
/// The file was fetched via SourceLink and is now available locally
112+
| SourceLinkFile of localFilePath: string<LocalPath> * originalRange: FSharp.Compiler.Text.Range
113+
/// The symbol is external but we couldn't resolve to a local file
114+
| External of assemblyName: string * range: FSharp.Compiler.Text.Range
115+
/// No location information available
116+
| NoLocation
117+
118+
let private resolveLogger = LogProvider.getLoggerByName "CallHierarchyHelpers"
119+
120+
/// Tries to resolve a symbol's declaration location to a local file path.
121+
/// For external symbols (from NuGet packages or FSharp.Core), attempts to fetch the source via SourceLink.
122+
/// This is a reusable helper that can be used for Call Hierarchy, Go to Definition, etc.
123+
let tryResolveExternalSymbolLocation (symbol: FSharpSymbol) : Async<ExternalSymbolLocationResult> =
124+
async {
125+
match symbol.DeclarationLocation with
126+
| None -> return ExternalSymbolLocationResult.NoLocation
127+
| Some declLoc ->
128+
// Check if the file exists locally
129+
if System.IO.File.Exists declLoc.FileName then
130+
return ExternalSymbolLocationResult.LocalFile(Utils.normalizePath declLoc.FileName, declLoc)
131+
else
132+
// File doesn't exist locally - try SourceLink
133+
resolveLogger.info (
134+
Log.setMessage "Declaration file does not exist locally, attempting SourceLink: {file}"
135+
>> Log.addContextDestructured "file" declLoc.FileName
136+
)
137+
138+
match symbol.Assembly.FileName with
139+
| None ->
140+
resolveLogger.warn (
141+
Log.setMessage "Symbol {symbol} has no assembly file path"
142+
>> Log.addContextDestructured "symbol" symbol.DisplayName
143+
)
144+
145+
return ExternalSymbolLocationResult.External(symbol.Assembly.SimpleName, declLoc)
146+
| Some assemblyPath ->
147+
let dllPath = Utils.normalizePath assemblyPath
148+
let sourceFile = getSourceFileSegment declLoc
149+
150+
match! Sourcelink.tryFetchSourcelinkFile dllPath sourceFile with
151+
| Ok localFilePath ->
152+
resolveLogger.info (
153+
Log.setMessage "Successfully fetched source via SourceLink: {localPath}"
154+
>> Log.addContextDestructured "localPath" localFilePath
155+
)
156+
157+
return ExternalSymbolLocationResult.SourceLinkFile(localFilePath, declLoc)
158+
| Error err ->
159+
resolveLogger.info (
160+
Log.setMessage "SourceLink fetch failed for {file}: {error}"
161+
>> Log.addContextDestructured "file" declLoc.FileName
162+
>> Log.addContextDestructured "error" err
163+
)
164+
165+
return ExternalSymbolLocationResult.External(symbol.Assembly.SimpleName, declLoc)
166+
}
167+
168+
open CallHierarchyHelpers
169+
53170
type AdaptiveFSharpLspServer
54171
(
55172
workspaceLoader: IWorkspaceLoader,
@@ -2182,7 +2299,147 @@ type AdaptiveFSharpLspServer
21822299

21832300
}
21842301

2302+
override x.CallHierarchyOutgoingCalls(p: CallHierarchyOutgoingCallsParams) =
2303+
asyncResult {
2304+
// OutgoingCalls finds all functions/methods called FROM the current symbol
2305+
let tags = [ "CallHierarchyOutgoingCalls", box p ]
2306+
use trace = fsacActivitySource.StartActivityForType(thisType, tags = tags)
2307+
2308+
try
2309+
logger.info (
2310+
Log.setMessage "CallHierarchyOutgoingCalls Request: {params}"
2311+
>> Log.addContextDestructured "params" p
2312+
)
2313+
2314+
let filePath = Path.FileUriToLocalPath p.Item.Uri |> Utils.normalizePath
2315+
let pos = protocolPosToPos p.Item.SelectionRange.Start
2316+
let! tyRes = state.GetTypeCheckResultsForFile filePath |> AsyncResult.ofStringErr
2317+
let! parseResults = state.GetParseResults filePath |> AsyncResult.ofStringErr
2318+
2319+
// Find the binding that contains our position
2320+
let containingBinding =
2321+
(pos, parseResults.ParseTree)
2322+
||> ParsedInput.tryPickLast (fun _path node ->
2323+
match node with
2324+
| SyntaxNode.SynBinding(SynBinding(headPat = _pat; expr = expr) as binding) when
2325+
Range.rangeContainsPos binding.RangeOfBindingWithRhs pos
2326+
->
2327+
Some(binding.RangeOfBindingWithRhs, expr)
2328+
| _ -> None)
2329+
2330+
match containingBinding with
2331+
| None -> return Some [||]
2332+
| Some(bindingRange, _bodyExpr) ->
2333+
2334+
// Get all symbol uses in the entire file
2335+
let allSymbolUses = tyRes.GetCheckResults.GetAllUsesOfAllSymbolsInFile()
2336+
2337+
// Filter to symbol uses within the function body, focusing only on calls
2338+
let bodySymbolUses = getOutgoingCallsInBinding bindingRange pos allSymbolUses
2339+
2340+
// Group symbol uses by the called symbol
2341+
let groupedBySymbol = bodySymbolUses |> Array.groupBy (fun su -> su.Symbol.FullName)
2342+
2343+
let createOutgoingCallItem (_symbolName: string, uses: FSharpSymbolUse[]) =
2344+
asyncOption {
2345+
if uses.Length = 0 then
2346+
do! None
2347+
2348+
let representativeUse = uses.[0]
2349+
let symbol = representativeUse.Symbol
2350+
2351+
// Convert the ranges where this symbol is called
2352+
let fromRanges = uses |> Array.map (fun u -> fcsRangeToLsp u.Range)
2353+
2354+
// Resolve the target file and location using SourceLink for external symbols
2355+
let! locationResult = tryResolveExternalSymbolLocation symbol
2356+
2357+
let targetLocation =
2358+
match locationResult with
2359+
| ExternalSymbolLocationResult.LocalFile(localPath, declLoc) ->
2360+
let targetUri = Path.LocalPathToUri localPath
2361+
let symbolKind = getSymbolKind symbol
2362+
let displayName = symbol.DisplayName
2363+
let detail = $"In {System.IO.Path.GetFileName(UMX.untag localPath)}"
2364+
2365+
{ CallHierarchyItem.Name = displayName
2366+
Kind = symbolKind
2367+
Tags = None
2368+
Detail = Some detail
2369+
Uri = targetUri
2370+
Range = fcsRangeToLsp declLoc
2371+
SelectionRange = fcsRangeToLsp declLoc
2372+
Data = None }
21852373

2374+
| ExternalSymbolLocationResult.SourceLinkFile(localPath, originalRange) ->
2375+
// SourceLink successfully fetched the file - use the local temp path
2376+
let targetUri = Path.LocalPathToUri localPath
2377+
let symbolKind = getSymbolKind symbol
2378+
let displayName = symbol.DisplayName
2379+
let detail = $"In {System.IO.Path.GetFileName(UMX.untag localPath)}"
2380+
2381+
{ CallHierarchyItem.Name = displayName
2382+
Kind = symbolKind
2383+
Tags = None
2384+
Detail = Some detail
2385+
Uri = targetUri
2386+
Range = fcsRangeToLsp originalRange
2387+
SelectionRange = fcsRangeToLsp originalRange
2388+
Data = None }
2389+
2390+
| ExternalSymbolLocationResult.External(assemblyName, declLoc) ->
2391+
// External symbol without SourceLink - provide what info we can
2392+
let symbolKind = getSymbolKind symbol
2393+
let displayName = symbol.DisplayName
2394+
let detail = $"In {assemblyName}"
2395+
2396+
// Use the original declaration location but note it's external
2397+
// The URI won't be navigable but at least provides context
2398+
let targetUri = Path.LocalPathToUri(Utils.normalizePath declLoc.FileName)
2399+
2400+
{ CallHierarchyItem.Name = displayName
2401+
Kind = symbolKind
2402+
Tags = None
2403+
Detail = Some detail
2404+
Uri = targetUri
2405+
Range = fcsRangeToLsp declLoc
2406+
SelectionRange = fcsRangeToLsp declLoc
2407+
Data = None }
2408+
2409+
| ExternalSymbolLocationResult.NoLocation ->
2410+
// Symbol without declaration location (e.g., built-in functions)
2411+
2412+
{ CallHierarchyItem.Name = symbol.DisplayName
2413+
Kind = getSymbolKind symbol
2414+
Tags = None
2415+
Detail = Some "Built-in"
2416+
Uri = p.Item.Uri // Use current file as fallback
2417+
Range = p.Item.Range
2418+
SelectionRange = p.Item.SelectionRange
2419+
Data = None }
2420+
2421+
return
2422+
{ CallHierarchyOutgoingCall.To = targetLocation
2423+
FromRanges = fromRanges }
2424+
}
2425+
2426+
let! outgoingCalls =
2427+
groupedBySymbol
2428+
|> Array.map createOutgoingCallItem
2429+
|> Async.parallel75
2430+
|> Async.map (Array.choose id)
2431+
2432+
return Some outgoingCalls
2433+
2434+
with e ->
2435+
trace |> Tracing.recordException e
2436+
2437+
let logCfg =
2438+
Log.setMessage "CallHierarchyOutgoingCalls Request Errored {p}"
2439+
>> Log.addContextDestructured "p" p
2440+
2441+
return! returnException e logCfg
2442+
}
21862443

21872444
override x.TextDocumentPrepareCallHierarchy(p: CallHierarchyPrepareParams) =
21882445
asyncResult {
@@ -3129,11 +3386,6 @@ type AdaptiveFSharpLspServer
31293386
return ()
31303387
}
31313388

3132-
member this.CallHierarchyOutgoingCalls
3133-
(_arg1: CallHierarchyOutgoingCallsParams)
3134-
: AsyncLspResult<CallHierarchyOutgoingCall array option> =
3135-
AsyncLspResult.notImplemented
3136-
31373389
member this.CancelRequest(_arg1: CancelParams) : Async<unit> = ignoreNotification
31383390
member this.NotebookDocumentDidChange(_arg1: DidChangeNotebookDocumentParams) : Async<unit> = ignoreNotification
31393391
member this.NotebookDocumentDidClose(_arg1: DidCloseNotebookDocumentParams) : Async<unit> = ignoreNotification

0 commit comments

Comments
 (0)