Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
d592c00
Add Call Hierarchy Outgoing Calls functionality and tests
TheAngryByrd Oct 1, 2025
db3fc45
Enhance Call Hierarchy functionality and tests by refining symbol use…
TheAngryByrd Oct 1, 2025
a915ec0
Add tests for recursive functions in Call Hierarchy, covering simple …
TheAngryByrd Oct 1, 2025
db8f254
Add nested call hierarchy examples and tests for deep navigation scen…
TheAngryByrd Oct 1, 2025
f29e883
Fix line numbers and comments in outgoing call tests for accuracy
TheAngryByrd Oct 2, 2025
57e3704
Remove obsolete test files for Call Hierarchy and Outgoing Calls
TheAngryByrd Oct 2, 2025
bfb3510
Remove commented-out code and simplify string interpolation in Adapti…
TheAngryByrd Oct 2, 2025
4e76a5e
Add call hierarchy helpers and tests for outgoing calls in local func…
TheAngryByrd Dec 9, 2025
97d4778
Add nested call hierarchy tests for parallel and multi-module workflo…
TheAngryByrd Dec 9, 2025
c0dedfd
Implement SourceLink integration for external symbols in Call Hierarc…
TheAngryByrd Dec 9, 2025
b82d88d
Add semaphore for concurrent downloads in SourceLink to prevent race …
TheAngryByrd Dec 9, 2025
b9ea054
Rename semaphore lock variable for clarity in downloadFileToTempDir f…
TheAngryByrd Dec 13, 2025
0a3e831
Enhance outgoing call tests to validate SourceLink file existence and…
TheAngryByrd Dec 13, 2025
44b4883
Fix formatting in outgoingTests to improve readability of path checks
TheAngryByrd Dec 13, 2025
ad14a1e
Refactor outgoingTests to improve handling of SourceLink behavior and…
TheAngryByrd Dec 14, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -242,4 +242,18 @@ This project uses **Paket** for dependency management instead of NuGet directly:
### Related Tools
- [FSharpLint](https://github.com/fsprojects/FSharpLint/) - Static analysis tool
- [Paket](https://fsprojects.github.io/Paket/) - Dependency management
- [FAKE](https://fake.build/) - Build automation (used for scaffolding)
- [FAKE](https://fake.build/) - Build automation (used for scaffolding)


### MCP Tools

> [!IMPORTANT]
You have access to a long-term memory system via the Model Context Protocol (MCP) at the endpoint `memorizer`. Use the following tools:
- `store`: Store a new memory. Parameters: `type`, `content` (markdown), `source`, `tags`, `confidence`, `relatedTo` (optional, memory ID), `relationshipType` (optional).
- `search`: Search for similar memories. Parameters: `query`, `limit`, `minSimilarity`, `filterTags`.
- `get`: Retrieve a memory by ID. Parameter: `id`.
- `getMany`: Retrieve multiple memories by their IDs. Parameter: `ids` (list of IDs).
- `delete`: Delete a memory by ID. Parameter: `id`.
- `createRelationship`: Create a relationship between two memories. Parameters: `fromId`, `toId`, `type`.
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.
8 changes: 8 additions & 0 deletions .vscode/mcp.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"servers": {
"ionide-memorizer": {
"url": "http://localhost:5001/sse",
}
},
"inputs": []
}
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@ This project uses **Paket** for dependency management instead of NuGet directly:
4. Include edge cases and error conditions
5. For code fixes: Run focused tests with `dotnet run -f net8.0 --project ./test/FsAutoComplete.Tests.Lsp/FsAutoComplete.Tests.Lsp.fsproj`
6. Remove focused test markers before submitting PRs (they cause CI failures)
7. Do not delete tests without permission.

### Test Data
- Sample F# projects in `TestCases/` directories
Expand Down Expand Up @@ -242,4 +243,4 @@ This project uses **Paket** for dependency management instead of NuGet directly:
### Related Tools
- [FSharpLint](https://github.com/fsprojects/FSharpLint/) - Static analysis tool
- [Paket](https://fsprojects.github.io/Paket/) - Dependency management
- [FAKE](https://fake.build/) - Build automation (used for scaffolding)
- [FAKE](https://fake.build/) - Build automation (used for scaffolding)
33 changes: 23 additions & 10 deletions src/FsAutoComplete.Core/Sourcelink.fs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ open FsAutoComplete.Logging
open FSharp.UMX
open FsAutoComplete.Utils
open Ionide.ProjInfo.ProjectSystem
open IcedTasks

let logger = LogProvider.getLoggerByName "FsAutoComplete.Sourcelink"

Expand Down Expand Up @@ -217,6 +218,8 @@ let private tryGetUrlForDocument (json: SourceLinkJson) (document: Document) =
else
tryGetUrlWithExactMatch path url document)

let sourceLinkSemaphore = new System.Threading.SemaphoreSlim(1, 1)

let private downloadFileToTempDir
(url: string<Url>)
(repoPathFragment: string<NormalizedRepoPathSegment>)
Expand All @@ -228,18 +231,28 @@ let private downloadFileToTempDir
let tempDir = Path.GetDirectoryName tempFile
Directory.CreateDirectory tempDir |> ignore

async {
logger.info (
Log.setMessage "Getting file from {url} for document {repoPath}"
>> Log.addContextDestructured "url" url
>> Log.addContextDestructured "repoPath" repoPathFragment
)
asyncEx {
use! _lock = sourceLinkSemaphore.LockAsync()
// Check if file already exists (cached from previous download)
if File.Exists tempFile then
logger.info (
Log.setMessage "Using cached SourceLink file for document {repoPath}"
>> Log.addContextDestructured "repoPath" repoPathFragment
)

return UMX.tag<LocalPath> tempFile
else
logger.info (
Log.setMessage "Getting file from {url} for document {repoPath}"
>> Log.addContextDestructured "url" url
>> Log.addContextDestructured "repoPath" repoPathFragment
)

let! response = httpClient.GetStreamAsync(UMX.untag url) |> Async.AwaitTask
let! response = httpClient.GetStreamAsync(UMX.untag url) |> Async.AwaitTask

use fileStream = File.OpenWrite tempFile
do! response.CopyToAsync fileStream |> Async.AwaitTask
return UMX.tag<LocalPath> tempFile
use fileStream = File.OpenWrite tempFile
do! response.CopyToAsync fileStream |> Async.AwaitTask
return UMX.tag<LocalPath> tempFile
}

type Errors =
Expand Down
262 changes: 257 additions & 5 deletions src/FsAutoComplete/LspServers/AdaptiveFSharpLspServer.fs
Original file line number Diff line number Diff line change
Expand Up @@ -44,12 +44,129 @@ open System.Threading.Tasks
open FsAutoComplete.FCSPatches
open Helpers
open System.Runtime.ExceptionServices
open FSharp.Compiler.CodeAnalysis

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

open ArrayHelpers

module CallHierarchyHelpers =
/// Determines if a symbol represents a callable (function, method, constructor, or applicable entity)
let isCallableSymbol (symbol: FSharpSymbol) =
match symbol with
| :? FSharpMemberOrFunctionOrValue as mfv ->
mfv.IsFunction
|| mfv.IsMethod
|| mfv.IsConstructor
|| (mfv.IsProperty
&& not (mfv.LogicalName.Contains("get_") || mfv.LogicalName.Contains("set_")))
| :? FSharpEntity as ent -> ent.IsClass || ent.IsFSharpRecord || ent.IsFSharpUnion
| _ -> false

/// Filters symbol uses to those within a binding range that represent outgoing calls
let getOutgoingCallsInBinding
(bindingRange: Range)
(pos: FSharp.Compiler.Text.Position)
(allSymbolUses: FSharpSymbolUse seq)
=
allSymbolUses
|> Seq.filter (fun su ->
Range.rangeContainsRange bindingRange su.Range
&& not su.IsFromDefinition
&& su.Range.Start <> pos
&& isCallableSymbol su.Symbol)
|> Seq.toArray

/// Gets the appropriate SymbolKind for a given FSharpSymbol
let getSymbolKind (symbol: FSharpSymbol) =
match symbol with
| :? FSharpMemberOrFunctionOrValue as mfv ->
if mfv.IsConstructor then SymbolKind.Constructor
elif mfv.IsProperty then SymbolKind.Property
elif mfv.IsMethod then SymbolKind.Method
elif mfv.IsEvent then SymbolKind.Event
else SymbolKind.Function
| :? FSharpEntity as ent ->
if ent.IsClass then SymbolKind.Class
elif ent.IsInterface then SymbolKind.Interface
elif ent.IsFSharpModule then SymbolKind.Module
elif ent.IsEnum then SymbolKind.Enum
elif ent.IsValueType then SymbolKind.Struct
else SymbolKind.Object
| _ -> SymbolKind.Function

/// Extracts the normalized file path segment from a declaration location, handling OS differences
let private getSourceFileSegment (loc: FSharp.Compiler.Text.Range) : string<NormalizedRepoPathSegment> =
if Ionide.ProjInfo.ProjectSystem.Environment.isWindows then
UMX.tag<NormalizedRepoPathSegment> loc.FileName
else
UMX.tag<NormalizedRepoPathSegment> (System.IO.Path.GetFileName loc.FileName)

/// Result of resolving an external symbol's source location
[<RequireQualifiedAccess>]
type ExternalSymbolLocationResult =
/// The file exists locally (either in workspace or already downloaded via SourceLink)
| LocalFile of filePath: string<LocalPath> * range: FSharp.Compiler.Text.Range
/// The file was fetched via SourceLink and is now available locally
| SourceLinkFile of localFilePath: string<LocalPath> * originalRange: FSharp.Compiler.Text.Range
/// The symbol is external but we couldn't resolve to a local file
| External of assemblyName: string * range: FSharp.Compiler.Text.Range
/// No location information available
| NoLocation

let private resolveLogger = LogProvider.getLoggerByName "CallHierarchyHelpers"

/// Tries to resolve a symbol's declaration location to a local file path.
/// For external symbols (from NuGet packages or FSharp.Core), attempts to fetch the source via SourceLink.
/// This is a reusable helper that can be used for Call Hierarchy, Go to Definition, etc.
let tryResolveExternalSymbolLocation (symbol: FSharpSymbol) : Async<ExternalSymbolLocationResult> =
async {
match symbol.DeclarationLocation with
| None -> return ExternalSymbolLocationResult.NoLocation
| Some declLoc ->
// Check if the file exists locally
if System.IO.File.Exists declLoc.FileName then
return ExternalSymbolLocationResult.LocalFile(Utils.normalizePath declLoc.FileName, declLoc)
else
// File doesn't exist locally - try SourceLink
resolveLogger.info (
Log.setMessage "Declaration file does not exist locally, attempting SourceLink: {file}"
>> Log.addContextDestructured "file" declLoc.FileName
)

match symbol.Assembly.FileName with
| None ->
resolveLogger.warn (
Log.setMessage "Symbol {symbol} has no assembly file path"
>> Log.addContextDestructured "symbol" symbol.DisplayName
)

return ExternalSymbolLocationResult.External(symbol.Assembly.SimpleName, declLoc)
| Some assemblyPath ->
let dllPath = Utils.normalizePath assemblyPath
let sourceFile = getSourceFileSegment declLoc

match! Sourcelink.tryFetchSourcelinkFile dllPath sourceFile with
| Ok localFilePath ->
resolveLogger.info (
Log.setMessage "Successfully fetched source via SourceLink: {localPath}"
>> Log.addContextDestructured "localPath" localFilePath
)

return ExternalSymbolLocationResult.SourceLinkFile(localFilePath, declLoc)
| Error err ->
resolveLogger.info (
Log.setMessage "SourceLink fetch failed for {file}: {error}"
>> Log.addContextDestructured "file" declLoc.FileName
>> Log.addContextDestructured "error" err
)

return ExternalSymbolLocationResult.External(symbol.Assembly.SimpleName, declLoc)
}

open CallHierarchyHelpers

type AdaptiveFSharpLspServer
(
workspaceLoader: IWorkspaceLoader,
Expand Down Expand Up @@ -2182,7 +2299,147 @@ type AdaptiveFSharpLspServer

}

override x.CallHierarchyOutgoingCalls(p: CallHierarchyOutgoingCallsParams) =
asyncResult {
// OutgoingCalls finds all functions/methods called FROM the current symbol
let tags = [ "CallHierarchyOutgoingCalls", box p ]
use trace = fsacActivitySource.StartActivityForType(thisType, tags = tags)

try
logger.info (
Log.setMessage "CallHierarchyOutgoingCalls Request: {params}"
>> Log.addContextDestructured "params" p
)

let filePath = Path.FileUriToLocalPath p.Item.Uri |> Utils.normalizePath
let pos = protocolPosToPos p.Item.SelectionRange.Start
let! tyRes = state.GetTypeCheckResultsForFile filePath |> AsyncResult.ofStringErr
let! parseResults = state.GetParseResults filePath |> AsyncResult.ofStringErr

// Find the binding that contains our position
let containingBinding =
(pos, parseResults.ParseTree)
||> ParsedInput.tryPickLast (fun _path node ->
match node with
| SyntaxNode.SynBinding(SynBinding(headPat = _pat; expr = expr) as binding) when
Range.rangeContainsPos binding.RangeOfBindingWithRhs pos
->
Some(binding.RangeOfBindingWithRhs, expr)
| _ -> None)

match containingBinding with
| None -> return Some [||]
| Some(bindingRange, _bodyExpr) ->

// Get all symbol uses in the entire file
let allSymbolUses = tyRes.GetCheckResults.GetAllUsesOfAllSymbolsInFile()

// Filter to symbol uses within the function body, focusing only on calls
let bodySymbolUses = getOutgoingCallsInBinding bindingRange pos allSymbolUses

// Group symbol uses by the called symbol
let groupedBySymbol = bodySymbolUses |> Array.groupBy (fun su -> su.Symbol.FullName)

let createOutgoingCallItem (_symbolName: string, uses: FSharpSymbolUse[]) =
asyncOption {
if uses.Length = 0 then
do! None

let representativeUse = uses.[0]
let symbol = representativeUse.Symbol

// Convert the ranges where this symbol is called
let fromRanges = uses |> Array.map (fun u -> fcsRangeToLsp u.Range)

// Resolve the target file and location using SourceLink for external symbols
let! locationResult = tryResolveExternalSymbolLocation symbol

let targetLocation =
match locationResult with
| ExternalSymbolLocationResult.LocalFile(localPath, declLoc) ->
let targetUri = Path.LocalPathToUri localPath
let symbolKind = getSymbolKind symbol
let displayName = symbol.DisplayName
let detail = $"In {System.IO.Path.GetFileName(UMX.untag localPath)}"

{ CallHierarchyItem.Name = displayName
Kind = symbolKind
Tags = None
Detail = Some detail
Uri = targetUri
Range = fcsRangeToLsp declLoc
SelectionRange = fcsRangeToLsp declLoc
Data = None }

| ExternalSymbolLocationResult.SourceLinkFile(localPath, originalRange) ->
// SourceLink successfully fetched the file - use the local temp path
let targetUri = Path.LocalPathToUri localPath
let symbolKind = getSymbolKind symbol
let displayName = symbol.DisplayName
let detail = $"In {System.IO.Path.GetFileName(UMX.untag localPath)}"

{ CallHierarchyItem.Name = displayName
Kind = symbolKind
Tags = None
Detail = Some detail
Uri = targetUri
Range = fcsRangeToLsp originalRange
SelectionRange = fcsRangeToLsp originalRange
Data = None }

| ExternalSymbolLocationResult.External(assemblyName, declLoc) ->
// External symbol without SourceLink - provide what info we can
let symbolKind = getSymbolKind symbol
let displayName = symbol.DisplayName
let detail = $"In {assemblyName}"

// Use the original declaration location but note it's external
// The URI won't be navigable but at least provides context
let targetUri = Path.LocalPathToUri(Utils.normalizePath declLoc.FileName)

{ CallHierarchyItem.Name = displayName
Kind = symbolKind
Tags = None
Detail = Some detail
Uri = targetUri
Range = fcsRangeToLsp declLoc
SelectionRange = fcsRangeToLsp declLoc
Data = None }

| ExternalSymbolLocationResult.NoLocation ->
// Symbol without declaration location (e.g., built-in functions)

{ CallHierarchyItem.Name = symbol.DisplayName
Kind = getSymbolKind symbol
Tags = None
Detail = Some "Built-in"
Uri = p.Item.Uri // Use current file as fallback
Range = p.Item.Range
SelectionRange = p.Item.SelectionRange
Data = None }

return
{ CallHierarchyOutgoingCall.To = targetLocation
FromRanges = fromRanges }
}

let! outgoingCalls =
groupedBySymbol
|> Array.map createOutgoingCallItem
|> Async.parallel75
|> Async.map (Array.choose id)

return Some outgoingCalls

with e ->
trace |> Tracing.recordException e

let logCfg =
Log.setMessage "CallHierarchyOutgoingCalls Request Errored {p}"
>> Log.addContextDestructured "p" p

return! returnException e logCfg
}

override x.TextDocumentPrepareCallHierarchy(p: CallHierarchyPrepareParams) =
asyncResult {
Expand Down Expand Up @@ -3129,11 +3386,6 @@ type AdaptiveFSharpLspServer
return ()
}

member this.CallHierarchyOutgoingCalls
(_arg1: CallHierarchyOutgoingCallsParams)
: AsyncLspResult<CallHierarchyOutgoingCall array option> =
AsyncLspResult.notImplemented

member this.CancelRequest(_arg1: CancelParams) : Async<unit> = ignoreNotification
member this.NotebookDocumentDidChange(_arg1: DidChangeNotebookDocumentParams) : Async<unit> = ignoreNotification
member this.NotebookDocumentDidClose(_arg1: DidCloseNotebookDocumentParams) : Async<unit> = ignoreNotification
Expand Down
Loading