Skip to content

Commit 2ee9d26

Browse files
committed
Add tests for cross-file active pattern references and enhance existing patterns
1 parent 4a9c22e commit 2ee9d26

File tree

8 files changed

+309
-19
lines changed

8 files changed

+309
-19
lines changed

src/FsAutoComplete.Core/Commands.fs

Lines changed: 30 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -842,16 +842,15 @@ module Commands =
842842
let dict = ConcurrentDictionary()
843843

844844
// For active patterns, we need to search for both the function and its cases
845-
// because FCS doesn't always find cross-file references correctly
845+
// because FCS doesn't always find cross-file references correctly.
846+
// The function symbol finds function-call style usages, the case symbol finds match-case usages.
846847
let symbolsToSearch =
847848
match symbol with
848849
| :? FSharpActivePatternCase as apCase ->
849-
// At a usage site: we have a case, also search for the declaring function
850-
// e.g., for case `ParseInt` in `(|ParseInt|_|)`, also search for the function
850+
// At a case symbol (either at declaration or usage)
851+
// Search for both the case (for match-case usages) AND the function (for function-call usages)
851852
match apCase.Group.DeclaringEntity with
852853
| Some entity ->
853-
// Search for the function using the pattern "|CaseName|" to match within the full pattern name
854-
// e.g., for case "ParseInt", search for function containing "|ParseInt|"
855854
let apcSearchString = $"|{apCase.DisplayName}|"
856855

857856
let declaringMember =
@@ -868,28 +867,24 @@ module Commands =
868867
| None -> [ symbol ]
869868
| None -> [ symbol ]
870869
| :? FSharpMemberOrFunctionOrValue as mfv when mfv.IsActivePattern ->
871-
// At a declaration site: this is an active pattern function
872-
// We need to search for both the function AND its individual cases to find all usages
870+
// At an active pattern function symbol - search for both the function AND its cases
873871
// FCS finds function-call style usages (e.g., `(|ParseFloat|_|) x`) for the function
874872
// FCS finds match-case style usages (e.g., `| ParseFloat x ->`) for the cases
875873
match mfv.DeclaringEntity with
876874
| Some entity ->
877-
// Get all active pattern cases in the entity and find those matching this function's cases
878875
let functionCases =
879876
try
880877
entity.ActivePatternCases
881878
|> Seq.filter (fun apc ->
882-
// Match case to function by checking if the function's display name contains the case name
883-
// e.g., for function "|ParseFloat|_|", case would be "ParseFloat"
884879
mfv.DisplayName.Contains($"|{apc.DisplayName}|", System.StringComparison.OrdinalIgnoreCase))
885880
|> Seq.map (fun apc -> apc :> FSharpSymbol)
886881
|> Seq.toList
887882
with _ ->
888883
[]
884+
889885
symbol :: functionCases
890886
| None -> [ symbol ]
891-
| :? FSharpMemberOrFunctionOrValue ->
892-
[ symbol ]
887+
| :? FSharpMemberOrFunctionOrValue -> [ symbol ]
893888
| _ -> [ symbol ]
894889

895890
/// Adds References of `symbol` in `file` to `dict`
@@ -910,9 +905,22 @@ module Commands =
910905
allReferences
911906
|> Array.concat
912907
// Deduplicate - when searching for multiple symbols (e.g., active pattern function + cases),
913-
// they may return duplicate ranges at the same location
914-
// Use distinctBy with a tuple key since Range objects may not be structurally equal
915-
|> Array.distinctBy (fun r -> r.StartLine, r.StartColumn, r.EndLine, r.EndColumn)
908+
// they may return overlapping ranges at the same location.
909+
// For example, at an active pattern declaration we might find both:
910+
// `|IsOneOfChoice|_|` (function symbol) and `IsOneOfChoice` (case symbol)
911+
// Keep only the outermost (longest) range when ranges overlap or are contained within each other.
912+
|> Array.groupBy (fun r -> r.StartLine)
913+
|> Array.collect (fun (_, rangesOnLine) ->
914+
// For ranges on the same line, filter out those that are contained within another
915+
rangesOnLine
916+
|> Array.filter (fun r ->
917+
rangesOnLine
918+
|> Array.exists (fun other ->
919+
// Check if 'other' strictly contains 'r' (r is nested inside other)
920+
other.StartColumn <= r.StartColumn
921+
&& other.EndColumn >= r.EndColumn
922+
&& (other.StartColumn < r.StartColumn || other.EndColumn > r.EndColumn))
923+
|> not))
916924
|> Array.toSeq
917925

918926
let references =
@@ -1035,6 +1043,13 @@ module Commands =
10351043
else
10361044
result.Add(k, v)
10371045

1046+
// Deduplicate across all symbol searches - when clicking on an active pattern,
1047+
// TryGetSymbolUses may return both the function and case symbol, leading to duplicates
1048+
for KeyValue(k, v) in result do
1049+
result.[k] <-
1050+
v
1051+
|> Array.distinctBy (fun r -> r.StartLine, r.StartColumn, r.EndLine, r.EndColumn)
1052+
10381053
return result
10391054
}
10401055

test/FsAutoComplete.Tests.Lsp/FindReferencesTests.fs

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -371,6 +371,109 @@ let private solutionTests state =
371371
Expect.locationsEqual getSource false refs expected
372372
}) ]) ])
373373

374+
/// Tests for ActivePatternProject - tests cross-file active pattern references
375+
let private activePatternProjectTests state =
376+
377+
let marker = "//>"
378+
379+
let readReferences path =
380+
let lines = File.ReadAllLines path
381+
let refs = Dictionary<string, IList<Location>>()
382+
383+
for i in 0 .. (lines.Length - 1) do
384+
let line = lines[i].TrimStart()
385+
386+
if line.StartsWith(marker, StringComparison.Ordinal) then
387+
let l = line.Substring(marker.Length).Trim()
388+
let splits = l.Split([| ' ' |], 2)
389+
let mark = splits[0]
390+
391+
let range =
392+
let col = line.IndexOf(mark, StringComparison.Ordinal)
393+
let length = mark.Length
394+
let line = i - 1 // marker is line AFTER actual range
395+
396+
{ Start =
397+
{ Line = uint32 line
398+
Character = uint32 col }
399+
End =
400+
{ Line = uint32 line
401+
Character = uint32 (col + length) } }
402+
403+
let loc =
404+
{ Uri = path |> normalizePath |> Path.LocalPathToUri
405+
Range = range }
406+
407+
let name = if splits.Length > 1 then splits[1] else ""
408+
409+
if not (refs.ContainsKey name) then
410+
refs[name] <- List<_>()
411+
412+
let existing = refs[name]
413+
existing.Add loc |> ignore
414+
415+
refs
416+
417+
let readAllReferences dir =
418+
let files = Directory.GetFiles(dir, "*.fs", SearchOption.AllDirectories)
419+
420+
files
421+
|> Seq.map readReferences
422+
|> Seq.map (fun dict -> dict |> Seq.map (fun kvp -> kvp.Key, kvp.Value))
423+
|> Seq.collect id
424+
|> Seq.groupBy fst
425+
|> Seq.map (fun (name, locs) -> (name, locs |> Seq.map snd |> Seq.collect id |> Seq.toArray))
426+
|> Seq.map (fun (name, locs) -> {| Name = name; Locations = locs |})
427+
|> Seq.toArray
428+
429+
430+
let path =
431+
Path.Combine(__SOURCE_DIRECTORY__, "TestCases", "FindReferences", "ActivePatternProject")
432+
433+
serverTestList "ActivePatternProject" state defaultConfigDto (Some path) (fun server ->
434+
[
435+
let mainDoc = "Patterns.fs"
436+
437+
documentTestList "inside Patterns.fs" server (Server.openDocument mainDoc) (fun doc ->
438+
[ let refs = readAllReferences path
439+
440+
for r in refs do
441+
testCaseAsync
442+
r.Name
443+
(async {
444+
let! (doc, _) = doc
445+
446+
let cursor =
447+
let cursor =
448+
r.Locations
449+
|> Seq.filter (fun l -> l.Uri = doc.Uri)
450+
|> Seq.minBy (fun l -> l.Range.Start)
451+
452+
cursor.Range.Start
453+
454+
let request: ReferenceParams =
455+
{ TextDocument = doc.TextDocumentIdentifier
456+
Position = cursor
457+
Context = { IncludeDeclaration = true }
458+
WorkDoneToken = None
459+
PartialResultToken = None }
460+
461+
let! refs = doc.Server.Server.TextDocumentReferences request
462+
463+
let refs =
464+
refs
465+
|> Flip.Expect.wantOk "Should not fail"
466+
|> Flip.Expect.wantSome "Should return references"
467+
468+
let expected = r.Locations
469+
470+
let getSource uri =
471+
let path = Path.FileUriToLocalPath uri
472+
File.ReadAllText path
473+
474+
Expect.locationsEqual getSource false refs expected
475+
}) ]) ])
476+
374477
/// multiple untitled files (-> all docs are unrelated)
375478
/// -> Tests for external symbols (-> over all docs) & symbol just in current doc (-> no matches in other unrelated docs)
376479
let private untitledTests state =
@@ -898,6 +1001,67 @@ let private rangeTests state =
8981001
| MyModule.$<Regex>$ @"\d+" v -> v
8991002
| _ -> ""
9001003
"""
1004+
testCaseAsync "can get range of inline struct partial Active Pattern - full pattern"
1005+
<|
1006+
// Inline struct partial active pattern - clicking on the full pattern (|StrStartsWith|_|)
1007+
// Tests that both function-call style usages like `(|StrStartsWith|_|) "hello" "world"`
1008+
// and match-case style usages like `| StrStartsWith "hello" ->` are found.
1009+
checkRanges
1010+
server
1011+
"""
1012+
module MyModule =
1013+
[<return: Struct>]
1014+
let inline ($D<|StrSta$0rtsWith|_|>D$) (prefix: string) (item: string) =
1015+
if item.StartsWith prefix then ValueSome () else ValueNone
1016+
1017+
// Function-call style usage in same module
1018+
let testDirect = ($<|StrStartsWith|_|>$) "hello" "hello world"
1019+
1020+
open MyModule
1021+
// Function-call style usage with open
1022+
let _ = ($<|StrStartsWith|_|>$) "hello" "hello world"
1023+
// Function-call style usage with qualified name
1024+
let _ = MyModule.($<|StrStartsWith|_|>$) "hello" "hello world"
1025+
// Match-case style usage
1026+
let _ =
1027+
match "hello world" with
1028+
| StrStartsWith "hello" -> true
1029+
| _ -> false
1030+
let _ =
1031+
match "hello world" with
1032+
| MyModule.StrStartsWith "hello" -> true
1033+
| _ -> false
1034+
"""
1035+
testCaseAsync "can get range of inline struct partial Active Pattern case"
1036+
<|
1037+
// When clicking on the case name in an inline struct partial active pattern
1038+
// Only match-case style usages are found for the case (FCS limitation)
1039+
// Function-call style usages use the full pattern, not individual cases
1040+
checkRanges
1041+
server
1042+
"""
1043+
module MyModule =
1044+
[<return: Struct>]
1045+
let inline (|$D<StrStartsWith>D$|_|) (prefix: string) (item: string) =
1046+
if item.StartsWith prefix then ValueSome () else ValueNone
1047+
1048+
// Function-call style usage - NOT marked because FCS doesn't find it for case symbols
1049+
let testDirect = (|StrStartsWith|_|) "hello" "hello world"
1050+
1051+
open MyModule
1052+
// Function-call style usages - NOT marked
1053+
let _ = (|StrStartsWith|_|) "hello" "hello world"
1054+
let _ = MyModule.(|StrStartsWith|_|) "hello" "hello world"
1055+
// Match-case style usages - these ARE found
1056+
let _ =
1057+
match "hello world" with
1058+
| $<StrSta$0rtsWith>$ "hello" -> true
1059+
| _ -> false
1060+
let _ =
1061+
match "hello world" with
1062+
| MyModule.$<StrStartsWith>$ "hello" -> true
1063+
| _ -> false
1064+
"""
9011065
testCaseAsync "can get range of type for static function call"
9021066
<| checkRanges
9031067
server
@@ -922,6 +1086,7 @@ let tests state =
9221086
"Find All References tests"
9231087
[ scriptTests state
9241088
solutionTests state
1089+
activePatternProjectTests state
9251090
untitledTests state
9261091
rangeTests state ]
9271092

test/FsAutoComplete.Tests.Lsp/TestCases/FindReferences/ActivePatternProject/Module1.fs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,3 +86,31 @@ module Module1 =
8686
match n with
8787
| DivisibleByStruct 3 result -> ValueSome result
8888
| _ -> ValueNone
89+
90+
// ============================================
91+
// INLINE GENERIC ACTIVE PATTERNS
92+
// ============================================
93+
94+
// Using IsOneOfChoice - inline generic struct parameterized pattern
95+
let checkIfStartsWithPrefix input =
96+
match input with
97+
| IsOneOfChoice ((|StrStartsWith|_|), ["hello"; "hi"; "hey"]) -> true
98+
//> ^^^^^^^^^^^^^ IsOneOfChoice
99+
| _ -> false
100+
101+
// Using IsOneOfChoice as a function
102+
let checkPrefixDirect input =
103+
(|IsOneOfChoice|_|) ((|StrStartsWith|_|), ["hello"; "hi"]) input
104+
//> ^^^^^^^^^^^^^^^^^ IsOneOfChoice
105+
106+
// Using StrStartsWithOneOf which uses IsOneOfChoice internally
107+
let checkGreeting input =
108+
match input with
109+
| StrStartsWithOneOf ["hello"; "hi"; "hey"] -> "greeting"
110+
| _ -> "not a greeting"
111+
112+
// Using StrStartsWith directly
113+
let startsWithHello input =
114+
match input with
115+
| StrStartsWith "hello" -> true
116+
| _ -> false

test/FsAutoComplete.Tests.Lsp/TestCases/FindReferences/ActivePatternProject/Module2.fs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,3 +86,24 @@ module Module2 =
8686
| Patterns.DivisibleByStruct 3 _ -> "divisible by 3 (struct)"
8787
| Patterns.DivisibleByStruct 5 _ -> "divisible by 5 (struct)"
8888
| _ -> "not divisible by 2, 3, or 5"
89+
90+
// ============================================
91+
// INLINE GENERIC ACTIVE PATTERNS (qualified access)
92+
// ============================================
93+
94+
// Using IsOneOfChoice as a function with qualified access
95+
let checkPrefixQualified input =
96+
Patterns.(|IsOneOfChoice|_|) (Patterns.(|StrStartsWith|_|), ["hello"; "hi"]) input
97+
//> ^^^^^^^^^^^^^^^^^ IsOneOfChoice
98+
99+
// Using StrStartsWithOneOf with qualified access
100+
let checkGreetingQualified input =
101+
match input with
102+
| Patterns.StrStartsWithOneOf ["hello"; "hi"; "hey"] -> "greeting"
103+
| _ -> "not a greeting"
104+
105+
// Using StrStartsWith with qualified access
106+
let startsWithHelloQualified input =
107+
match input with
108+
| Patterns.StrStartsWith "hello" -> true
109+
| _ -> false

test/FsAutoComplete.Tests.Lsp/TestCases/FindReferences/ActivePatternProject/Patterns.fs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
namespace ActivePatternProject
22

3+
module Seq =
4+
let inline tryPickV chooser (source: seq<'T>) =
5+
use e = source.GetEnumerator()
6+
let mutable res = ValueNone
7+
while (ValueOption.isNone res && e.MoveNext()) do
8+
res <- chooser e.Current
9+
res
10+
311
/// Module containing various active pattern definitions
412
module Patterns =
513

@@ -83,3 +91,19 @@ module Patterns =
8391
let inline (|DivisibleByStruct|_|) divisor value =
8492
if value % divisor = 0 then ValueSome(value / divisor)
8593
else ValueNone
94+
95+
96+
[<return: Struct>]
97+
let inline (|IsOneOfChoice|_|) (chooser: 'a -> 'b -> 'c voption, values : 'a seq) (item : 'b) =
98+
//> ^^^^^^^^^^^^^^^^^ IsOneOfChoice
99+
values |> Seq.tryPickV (fun x -> chooser x item)
100+
101+
[<return: Struct>]
102+
let inline (|StrStartsWith|_|) (value : string) (item : string) =
103+
if item.StartsWith value then ValueSome ()
104+
else ValueNone
105+
106+
[<return: Struct>]
107+
let inline (|StrStartsWithOneOf|_|) (values : string seq) (item : string) =
108+
(|IsOneOfChoice|_|) ((|StrStartsWith|_|), values) item
109+
//> ^^^^^^^^^^^^^^^^^ IsOneOfChoice

test/FsAutoComplete.Tests.Lsp/TestCases/FindReferences/Solution/B/MyModule3.fs

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,23 @@ let classifyInModule3 n =
2727
| Even -> "even"
2828
| Odd -> "odd"
2929

30-
// Partial active pattern usage
30+
// Partial active pattern usage (cross-file)
31+
// NOTE: No markers here - see WorkingModule.fs for explanation of FCS limitations
3132
let _ = (|ParseInt|_|) "999"
3233
let parseInModule3 input =
3334
match input with
3435
| ParseInt n -> Some n
35-
| _ -> None
36+
| _ -> None
37+
38+
// ============================================
39+
// INLINE ACTIVE PATTERN CROSS-FILE USAGES
40+
// NOTE: No markers - see B/WorkingModule.fs for explanation of FCS limitations
41+
// ============================================
42+
43+
// Function-call style usage cross-file
44+
let _ = (|StrPrefix|_|) "hi" "hi there"
45+
// Match-case style usage cross-file
46+
let checkPrefix input =
47+
match input with
48+
| StrPrefix "test" -> true
49+
| _ -> false

0 commit comments

Comments
 (0)