diff --git a/.claude/rules/CcrOptout.md b/.claude/rules/CcrOptout.md new file mode 100644 index 00000000000..a4e911e2dca --- /dev/null +++ b/.claude/rules/CcrOptout.md @@ -0,0 +1,6 @@ +--- +paths: + - "**" +--- + +@../../.github/instructions/CcrOptout.instructions.md diff --git a/.claude/rules/CodeGen.md b/.claude/rules/CodeGen.md new file mode 100644 index 00000000000..53e32af521c --- /dev/null +++ b/.claude/rules/CodeGen.md @@ -0,0 +1,6 @@ +--- +paths: + - "src/Compiler/CodeGen/**/*.{fs,fsi}" +--- + +@../../.github/instructions/CodeGen.instructions.md diff --git a/.claude/rules/ComponentTests.md b/.claude/rules/ComponentTests.md new file mode 100644 index 00000000000..a71b46bae53 --- /dev/null +++ b/.claude/rules/ComponentTests.md @@ -0,0 +1,6 @@ +--- +paths: + - "tests/FSharp.Compiler.ComponentTests/**/*.fs" +--- + +@../../.github/instructions/ComponentTests.instructions.md diff --git a/.claude/rules/DebugEmit.md b/.claude/rules/DebugEmit.md new file mode 100644 index 00000000000..a58978e5d3a --- /dev/null +++ b/.claude/rules/DebugEmit.md @@ -0,0 +1,6 @@ +--- +paths: + - "src/Compiler/AbstractIL/ilwritepdb.{fs,fsi}" +--- + +@../../.github/instructions/DebugEmit.instructions.md diff --git a/.claude/rules/EngVersioning.md b/.claude/rules/EngVersioning.md new file mode 100644 index 00000000000..76483c8d6de --- /dev/null +++ b/.claude/rules/EngVersioning.md @@ -0,0 +1,9 @@ +--- +paths: + - "eng/Packages.props" + - "eng/Versions.props" + - "eng/Version.Details.xml" + - "eng/Version.Details.props" +--- + +@../../.github/instructions/EngVersioning.instructions.md diff --git a/.claude/rules/ExpertReview.md b/.claude/rules/ExpertReview.md new file mode 100644 index 00000000000..450e5c6e77f --- /dev/null +++ b/.claude/rules/ExpertReview.md @@ -0,0 +1,6 @@ +--- +paths: + - "src/Compiler/**/*.{fs,fsi}" +--- + +@../../.github/instructions/ExpertReview.instructions.md diff --git a/.claude/rules/FSComp.md b/.claude/rules/FSComp.md new file mode 100644 index 00000000000..95379c7e34d --- /dev/null +++ b/.claude/rules/FSComp.md @@ -0,0 +1,6 @@ +--- +paths: + - "src/Compiler/FSComp.txt" +--- + +@../../.github/instructions/FSComp.instructions.md diff --git a/.claude/rules/FSharp.md b/.claude/rules/FSharp.md new file mode 100644 index 00000000000..ab9f89607df --- /dev/null +++ b/.claude/rules/FSharp.md @@ -0,0 +1,8 @@ +--- +paths: + - "src/**/*.{fs,fsi,fsx}" + - "vsintegration/src/**/*.{fs,fsi}" + - "tests/**/*.{fs,fsi,fsx}" +--- + +@../../.github/instructions/FSharp.instructions.md diff --git a/.claude/rules/FSharpCore.md b/.claude/rules/FSharpCore.md new file mode 100644 index 00000000000..dc879081eac --- /dev/null +++ b/.claude/rules/FSharpCore.md @@ -0,0 +1,6 @@ +--- +paths: + - "src/FSharp.Core/**/*.{fs,fsi}" +--- + +@../../.github/instructions/FSharpCore.instructions.md diff --git a/.claude/rules/LSP.md b/.claude/rules/LSP.md new file mode 100644 index 00000000000..04f1c6ae806 --- /dev/null +++ b/.claude/rules/LSP.md @@ -0,0 +1,6 @@ +--- +paths: + - "src/FSharp.Compiler.LanguageServer/**/*.{fs,fsi}" +--- + +@../../.github/instructions/LSP.instructions.md diff --git a/.claude/rules/NoBloat.md b/.claude/rules/NoBloat.md new file mode 100644 index 00000000000..f2554b4c0ab --- /dev/null +++ b/.claude/rules/NoBloat.md @@ -0,0 +1,10 @@ +--- +paths: + - "src/Compiler/**/*.{fs,fsi}" + - "vsintegration/src/**/*.{fs,fsi}" + - "tests/FSharp.Compiler.ComponentTests/**/*.fs" + - "tests/FSharp.Compiler.Service.Tests/**/*.fs" + - "vsintegration/tests/**/*.fs" +--- + +@../../.github/instructions/NoBloat.instructions.md diff --git a/.claude/rules/Optimizer.md b/.claude/rules/Optimizer.md new file mode 100644 index 00000000000..41eb3aacec7 --- /dev/null +++ b/.claude/rules/Optimizer.md @@ -0,0 +1,6 @@ +--- +paths: + - "src/Compiler/Optimize/**/*.{fs,fsi}" +--- + +@../../.github/instructions/Optimizer.instructions.md diff --git a/.claude/rules/SyntaxTree.md b/.claude/rules/SyntaxTree.md new file mode 100644 index 00000000000..496da4a2cf4 --- /dev/null +++ b/.claude/rules/SyntaxTree.md @@ -0,0 +1,9 @@ +--- +paths: + - "src/Compiler/SyntaxTree/SyntaxTree.{fs,fsi}" + - "src/Compiler/SyntaxTree/SyntaxTreeOps.{fs,fsi}" + - "src/Compiler/SyntaxTree/ParseHelpers.{fs,fsi}" + - "src/Compiler/pars.fsy" +--- + +@../../.github/instructions/SyntaxTree.instructions.md diff --git a/.claude/rules/TypedTreePickle.md b/.claude/rules/TypedTreePickle.md new file mode 100644 index 00000000000..ec6a818ca9c --- /dev/null +++ b/.claude/rules/TypedTreePickle.md @@ -0,0 +1,8 @@ +--- +paths: + - "src/Compiler/TypedTree/TypedTreePickle.{fs,fsi}" + - "src/Compiler/TypedTree/TypedTree.{fs,fsi}" + - "src/Compiler/Driver/CompilerImports.{fs,fsi}" +--- + +@../../.github/instructions/TypedTreePickle.instructions.md diff --git a/.github/instructions/FSharp.instructions.md b/.github/instructions/FSharp.instructions.md new file mode 100644 index 00000000000..3d2f0ad2790 --- /dev/null +++ b/.github/instructions/FSharp.instructions.md @@ -0,0 +1,90 @@ +--- +applyTo: + - "src/**/*.{fs,fsi,fsx}" + - "vsintegration/src/**/*.{fs,fsi}" + - "tests/**/*.{fs,fsi,fsx}" +--- + +# Writing F# + +Which constructs to reach for. Code shape and comments are NoBloat's; naming and the abbreviation glossary belong to `docs/coding-standards.md` and `DEVGUIDE.md`. + +These rules govern code you write or touch. Do not sweep the codebase to apply them – `CONTRIBUTING.md`: *"DO NOT submit large code formatting changes without discussing with the team first."* + +## Tools + +When the IDE's F# semantic tools are unavailable, use the `F#` MCP server (`.mcp.json`, FsLangMCP) for navigation, symbol discovery, diagnostics and cross-project usage search. Prefer its semantic tools over plain text search for F#-specific work. + +## Strings + +- Prefer interpolated strings over `sprintf` and `String.Format`. Use `$"""…"""` when the text itself contains quotes. +- Format specifiers are valid in interpolated strings and help type inference: `$"count %d{n}"`. +- `nameof` over a string literal that names a value, member or type. +- An explicit `StringComparison` on every `Equals`, `StartsWith`, `EndsWith`, `Contains`, `IndexOf` and `Compare`, and an explicit comparer on every `HashSet` and `Dictionary`. `Ordinal` by default, `OrdinalIgnoreCase` for identifiers and paths. Culture-sensitive comparison is a decision, never a default. + +## Values and types + +- `voption` – `ValueSome`/`ValueNone` – over `option` when the value does not escape; it is this compiler's option type. Exception: when an API hands you `'T option` (`Seq.tryHead`, `List.tryFind`), unwrap with `Option.defaultValue`/`Option.defaultWith` directly – do not insert `ValueOption.ofOption` just to switch modules. +- `struct ('T1 * 'T2)` tuples and `[]` types on allocation-sensitive paths. +- Anonymous struct records (`struct {| … |}`) over bare tuples for multi-value returns of internal helpers. Public FCS surface is governed by `.fsi` files and compatibility – do not change it for style. +- The compiler generates `IsCaseName` properties (`IsDefault`, `IsCustom`) for DU cases – use them for a specific-case check instead of a full `match`. +- Deconstruct `KeyValuePair` with the `KeyValue` active pattern: `for KeyValue(k, v) in map do …`. +- `[]` on `inline` higher-order helpers whose lambda argument must not become a closure. + +## Lambdas and collections + +- Prefer the `_.Property` shorthand in pipeline position: `tys |> List.map _.Type`. Complex expressions (`fun x -> x.Name = name`, `fun x -> x.A, x.B`) cannot use it. Never add a space – `_.MethodCall ()` breaks parsing. Unrelated to the `member _.Foo` self-identifier. +- Eta-reduce: `Seq.map (fun x -> someFunction x)` must become `Seq.map someFunction`. +- Prefer a single traversal – one `fold`, loop, or comprehension – to a chain of transformations: it allocates nothing per element, where a chain allocates at every stage. +- When the chain reads better than one pass, route it through `Seq` and materialize once at the end – a `List`/`Array` chain allocates a whole intermediate collection per stage, a `Seq` chain only an enumerator. +- Concatenate with `[ yield! xs; yield! ys ]` / `seq { yield! xs; yield! ys }` rather than `@` or `Seq.append` – `@` forces both sides to lists and is O(n). +- Cast sequence items with `Seq.cast`, not `Seq.map (fun item -> item :> Target)`. + +## Async and exceptions + +- `src/Compiler` targets `netstandard2.0`: prefer `async { }` and the repo's `cancellable { }` (`src/Compiler/Utilities/Cancellable.fs`). `task { }` appears only in `Service/FSharpProjectSnapshot.fs` and `Service/FSharpWorkspaceQuery.fs` – avoid it elsewhere in new core code. +- `vsintegration` runs on the VS threading model where `task { }` is at home. When an override must return non-generic `Task`, annotate explicitly – `override _.M(…) : Task = task { … }` – never cast through pipelines. +- Thread cancellation through; see `ExpertReview.instructions.md`. +- `reraise ()` does not compile inside a `task`/`async` CE (FS0413). There, rethrow with `ExceptionDispatchInfo.Capture(ex).Throw()`; outside CEs plain `reraise ()` is correct. + +## Nullness + +Enabled in `src/Compiler`, `src/FSharp.Build`, `src/FSharp.Compiler.LanguageServer`. There: + +- Declare non-nullable; check for `null` at entry points. Trust C#/F# annotations – no null checks where the type system says a value cannot be null. +- Prefer `match x with | null -> … | x -> …` over `isNull` – the match narrows the type, `isNull` does not. +- Use `withNull` to hint nullability instead of boxing (`isNull (box f)`). +- Before suppressing a nullness warning (3261, 3262, …), escalate in order: `nonNull value` (runtime assert, fail fast) → `Unchecked.nonNull value` (null provably impossible upstream) → inline `#nowarn`/`#warnon` pair, centralised in one interop helper rather than scattered across call sites. + +## Warning suppression + +Inline `#nowarn "NN"` / `#warnon "NN"` pairs around the smallest possible scope – they are valid anywhere in an `.fs` file, not only at the top. File-level suppression is a last resort. + +## Classes (mostly `vsintegration`) + +- Initializer syntax over post-construction property assignment: `MyType(ctorArg, MutableProp1 = v1, MutableProp2 = (5 |> string))` – settable properties by name after positional arguments, computed values in parentheses. +- Extension members consumable from C#: `[]` module, `[]` on each member. +- Prefer a root module (`module Ns.FeatureExtensions`) over `namespace` + a static holder type for extension files; name it after the single target type plus `Extensions`, or after the feature when there are several targets. +- XML doc comments that use markup (``, ``) need their text wrapped in ``. + +## Opens + +Sort into blank-line-separated groups, alphabetically within each: `System.*` → `Microsoft.*` → `Internal.Utilities.*` → `FSharp.Compiler.*` (see the top of `src/Compiler/Service/TransparentCompiler.fs`). `open type` last within its group; type and module aliases at the very end. + +## Do not mistake for conventions + +This codebase implements every F# feature, so finding one here is no evidence that it is used here. These have no foothold – introducing them is a new pattern, not a continuation: + +- `while!` and `and!` – no uses at all; the matches are the parser and the checker implementing them. +- `[]` – a handful of deliberate assertions on specific recursive functions, not a habit. + +## The language version is not uniform + +`FSharp.Profiles.props` sets `LangVersion=preview`, but not under `Configuration=Proto` and not when `BUILDING_USING_DOTNET=true`. The compiler bootstraps, so a feature this repository is *adding* cannot be used in its own source until it ships in the SDK compiler named by `global.json` – otherwise the Proto stage fails. + +- `src/FSharp.Build` is pinned to `LangVersion 9`; it can load in Visual Studio against an older FSharp.Core. +- `src/FSharp.Core` leaves nullness off and is bound by `docs/fsharp-core-notes.md`. + +## Tests + +The ComponentTests DSL and its pipeline are covered by `ComponentTests.instructions.md`. Name tests with backticked spaces: ``let ``Issue 12345 - brief description`` () = …``. diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000000..ac883d4f548 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,3 @@ +# Agent Instructions + +See [`.github/copilot-instructions.md`](.github/copilot-instructions.md) for build, test, and coding rules for this repository. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000000..8b5dd8a7143 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,18 @@ +@.github/copilot-instructions.md + +## Visual Studio first + +The `vs` MCP server reaches the Visual Studio instance holding this repo's solution. Where it and the shell both work, use it — it reports what the IDE's compiler and symbol graph know, not what the text files say. + +- Build with `build_solution` / `build_project` / `build_clean` rather than `dotnet build` or `msbuild`; the Build section above is the fallback for when no solution is loaded (`ide_get_workspace_folders` is empty). +- Read errors from `ide_get_diagnostics`, not by parsing build output. +- Navigate and rename with `nav_go_to_definition`, `nav_find_references`, `nav_search_workspace_symbols`, `nav_rename_symbol` — the symbol graph, not `grep` plus hand edits. +- Format what you touched with `document_format` / `document_organize_imports` before reaching for `dotnet fantomas`. + +Before editing a file on disk, `document_check_dirty` it. An unsaved VS buffer is the real content: read it with `document_read_buffer` and `document_save` first, or the edit lands on stale text and the user's next save reverts it. + +`build_*` compiles whichever configuration the IDE has active, and `solution_set_configuration` changes it for the user's next manual build too — read `solution_get_configuration` before assuming `Debug`. + +`project_add_file` appends without a position, so it cannot place a new `` correctly in an order-sensitive F# project: add those to the `.fsproj` by hand. + +Tests have no IDE path — the `vs` server exposes no Test Explorer tools (https://github.com/Corsinvest/cv4vs-agents/issues/192), so they run through `dotnet test` as described above. diff --git a/docs/ide/file-watching.md b/docs/ide/file-watching.md new file mode 100644 index 00000000000..53c00399a63 --- /dev/null +++ b/docs/ide/file-watching.md @@ -0,0 +1,66 @@ +# File watching in FSharp.Editor + +`vsintegration/src/FSharp.Editor/LanguageService/FileChangeWatcher.fs` is the F# counterpart of +Roslyn's `FileChangeWatcher` and `ReferenceFileChangeTracker` (both internal to +`Microsoft.VisualStudio.LanguageServices` and not exposed through `ExternalAccess.FSharp`). + +## What the workspace already gives us + +Not every on-disk change needs this watcher. A `-r:` that the Roslyn workspace holds as a +`MetadataReference` is already watched by Roslyn: `ProjectSystemProjectFactory` advises every +reference path, and when one changes it swaps the reference on the solution, which bumps +`Project.Version`. `FSharpProjectOptionsReactor` sees that version through `isProjectInvalidated`, +recomputes, and calls `InvalidateConfiguration` — measured in VS, that path wins the race against +a watcher subscribed to the same file, because Roslyn batches over 500 ms where this tracker +additionally debounces for 2 s. + +So a second subscription to the same reference set buys nothing. What the workspace does *not* +cover is everything it has no document or reference for, and every stat FCS still performs +internally: + +- `#load` sources of a script: not documents, not references, invisible to the workspace. +- `IsReferencesInvalidated` on the incremental builder, which stats every reference on every + request. +- `ReferencesOnDisk` on snapshot reuse, which did the same per comparison — the consumer below. + +## Shape + +- **Service.** `IVsAsyncFileChangeEx2`, obtained asynchronously; nothing ever blocks on the UI + thread. Callbacks arrive through `IVsFreeThreadedFileChangeEvents2` and stay on background + threads. +- **Batching.** Subscribe/unsubscribe operations go through a single-consumer queue with a 500 ms + window (Roslyn's empirical value for solution open/close). Consecutive operations of the same + kind, and for file watches the same sink, are coalesced into one service call. +- **Directory watches.** A context starts with recursive `.dll` watches on the places reference + assemblies live: `DOTNET_ROOT/packs` and the machine-wide `dotnet/packs`, the .NET Framework + reference assemblies, and the NuGet cache (`NUGET_PACKAGES` or `~/.nuget/packages`). A file + under one of them costs no cookie of its own. Roslyn does not watch the NuGet cache; a consumer + that watches every `-r:` uniformly wants it, since package assemblies are the bulk of them and + the alternative is a per-file advise for each. +- **Per-file watches.** Paths outside those directories (project outputs, loose assemblies) get + an individual advise, ref-counted across consumers by `FSharpReferenceChangeTracker`. The + tracker also keeps the last-write stamp of each watched path (see Consumers). +- **Debounce.** A rebuild writes a temp file and renames it, producing several notifications; the + tracker fires one callback per path after 2 s of quiet. + +## Consumers + +**Snapshot reference stamps.** `FSharpProjectOptionsReactor` watches the `-r:` set of every +project it computes options for, diffed on recompute so an unchanged set touches nothing. The +tracker keeps each path's last-write stamp inside its watch entry and drops it on the raw change +notification, before the debounce. The `ReferencesOnDisk` guard in `createProjectSnapshot` reads +stamps through `IReferenceStamps`, so the comparison that runs for every new `Project` instance is +a dictionary read per reference instead of a stat. A path nobody watches is stat'd directly. A +mismatch against the snapshot's own stamps (FCS stats when it builds a snapshot) drops the +project's stamps, so a missed notification costs one re-stat pass rather than a rebuild per +`Project` instance. The reactor watch exists for these stamps, not to invalidate the FCS build — +Roslyn already does that, as the previous section says. + +Still to come: + +1. Scripts: watch `#load` sources so an edit outside the editor drops the cached options for + that document. +2. `FSharpProjectSnapshot.FromOptions` stats every `-r:` when a snapshot is built from scratch; + an overload taking host-supplied stamps lets it read the same cache. +3. A reference-change notification for the incremental builder on the FCS side, the analogue of + `useChangeNotifications` for sources, so `IsReferencesInvalidated` stops stat'ing at all. diff --git a/docs/release-notes/.FSharp.Compiler.Service/11.0.100.md b/docs/release-notes/.FSharp.Compiler.Service/11.0.100.md index 9441f8587e0..4cbb4e2321d 100644 --- a/docs/release-notes/.FSharp.Compiler.Service/11.0.100.md +++ b/docs/release-notes/.FSharp.Compiler.Service/11.0.100.md @@ -1,5 +1,8 @@ ### Fixed +* Debug builds of FCS no longer write `StandardOutput.txt`/`StandardError.txt` into the project (or script) directory when probing `dotnet --version` for the SDK. With a Debug VSIX in Visual Studio those files landed inside SDK-style project folders and kept re-triggering project re-evaluation -> design-time build -> NuGet restore -> F# re-check. ([PR #20458](https://github.com/dotnet/fsharp/pull/20458)) +* Fix `StackOverflowException` when checking a long `seq { ... }` body. The `expr; expr; ...` spine of a sequence expression was recursed without a stack guard, and the post-inference `CheckNoReraise` walked the resulting right-nested `Seq.append`/`Seq.delay` tree with unguarded free-variable options, so a few hundred (Debug) to a few thousand (Release) implicit-yield elements overflowed a 1 MB thread-pool thread in the IDE. `tcSequenceExprBodyAsSequenceOrStatement` now runs under `cenv.stackGuard` and `CheckNoReraise` uses `CollectLocalsWithStackGuard`. +* Fix `StackOverflowException` when checking a long `seq { ... }` body. The `expr; expr; ...` spine of a sequence expression was recursed without a stack guard, and the post-inference `CheckNoReraise` walked the resulting right-nested `Seq.append`/`Seq.delay` tree with unguarded free-variable options, so a few hundred (Debug) to a few thousand (Release) implicit-yield elements overflowed a 1 MB thread-pool thread in the IDE. `tcSequenceExprBodyAsSequenceOrStatement` now runs under `cenv.stackGuard` and `CheckNoReraise` uses `CollectLocalsWithStackGuard`. ([PR #20480](https://github.com/dotnet/fsharp/pull/20480)) * Fix `NativePtr.stackalloc` nested in a larger expression (e.g. a call argument or the right of an assignment) producing an assembly that throws `InvalidProgramException` at load. ([Issue #8083](https://github.com/dotnet/fsharp/issues/8083), [PR #20302](https://github.com/dotnet/fsharp/pull/20302)) * Fix internal error "Unexpected generalized type variables when compiling an active pattern" when an active pattern is used in a `let` binding whose right-hand side is a generic value, e.g. `let (T) = id`. Such a binding is now checked like the equivalent `match` and is not generalized. ([Issue #16856](https://github.com/dotnet/fsharp/issues/16856), [PR #20383](https://github.com/dotnet/fsharp/pull/20383)) * Fix Release-only (`--optimize+`) `System.InvalidProgramException` from `Seq.collect` / `yield!` over a value-type (struct) collection implementing `seq<'T>` (e.g. `ImmutableArray<_>`) when materialised with `List.ofSeq` / `Seq.toList` / `Seq.toArray` or a list/array comprehension. The collector lowering now boxes a struct sub-collection to `seq<'T>` before calling `AddMany`/`AddManyAndClose` (matching the coercion the type checker already inserts for `yield!`), and uses `unit` as the try/finally result type instead of the body type (removing a spurious `ldnull` store). ([Issue #20203](https://github.com/dotnet/fsharp/issues/20203)) @@ -59,6 +62,7 @@ * Narrow overload-resolution error ranges to the method name only instead of covering the entire expression. ([Issue #14284](https://github.com/dotnet/fsharp/issues/14284), [PR #19505](https://github.com/dotnet/fsharp/pull/19505)) * Fix attributes not resolved from opened namespaces in `namespace rec` / `module rec` scopes. ([Issue #7931](https://github.com/dotnet/fsharp/issues/7931), [PR #19502](https://github.com/dotnet/fsharp/pull/19502)) * Fix DU case names matching IWSAM member names no longer cause duplicate property entries. (Issue [#14321](https://github.com/dotnet/fsharp/issues/14321), [PR #19341](https://github.com/dotnet/fsharp/pull/19341)) +* Recover per symbol instead of per file in `GetSemanticClassification`, so one unresolvable symbol no longer drops the whole file's classification, and fail fast in the pickle writer when an entity was never linked. ([Issue #20269](https://github.com/dotnet/fsharp/issues/20269), [PR #20274](https://github.com/dotnet/fsharp/pull/20274)) * Fix DefaultAugmentation(false) duplicate entry in method table. (Issue [#16565](https://github.com/dotnet/fsharp/issues/16565), [PR #19341](https://github.com/dotnet/fsharp/pull/19341)) * Fix abstract event accessors now have SpecialName flag. (Issue [#5834](https://github.com/dotnet/fsharp/issues/5834), [PR #19341](https://github.com/dotnet/fsharp/pull/19341)) * Fix warning 20 ("expression is implicitly ignored") pointing at the wrong range when the last expression in a sequential block (e.g. inside `for`, `while` loops) is non-unit. The squiggle now correctly highlights only the offending expression. ([Issue #5418](https://github.com/dotnet/fsharp/issues/5418), [PR #19504](https://github.com/dotnet/fsharp/pull/19504)) @@ -146,8 +150,10 @@ * Import: Don't walk non-F# assemblies when labelling trait constraint sources (PR [#20090](https://github.com/dotnet/fsharp/pull/20090)) * Avoid per-instance lock object in InterruptibleLazy and DelayInitArrayMap (PR [#20088](https://github.com/dotnet/fsharp/pull/20088)) * IL: fix leaking binary view ([PR #20250](https://github.com/dotnet/fsharp/pull/20250)) +* A project reusing cached framework imports is checked with its own `--pathmap` instead of the map of the project that filled the cache, so the ranges of its in-memory reference data name its real files. ([Issue #20474](https://github.com/dotnet/fsharp/issues/20474), [PR #20476](https://github.com/dotnet/fsharp/pull/20476)) ### Added +* `Internal.Utilities.Library`: `String.StartsWithOrdinalIgnoreCase` extension, the `StartsWith` sibling of `EndsWithOrdinalIgnoreCase`. ([PR #20457](https://github.com/dotnet/fsharp/pull/20457)) * Added a "most concrete" tiebreaker for overload resolution (`--langversion:preview`). ([RFC FS-1340](https://github.com/fsharp/fslang-design/pull/834), [PR #19277](https://github.com/dotnet/fsharp/pull/19277)) * Added support for `OverloadResolutionPriorityAttribute` in overload resolution (`--langversion:preview`). ([RFC FS-1338](https://github.com/fsharp/fslang-design/pull/828), [PR #19277](https://github.com/dotnet/fsharp/pull/19277)) @@ -178,6 +184,7 @@ * IL: hold custom attributes in fields rather than a union case ([PR #20287](https://github.com/dotnet/fsharp/pull/20287)) * IL: reuse the cached ILTypeRef in ILTypeInfo.FromType ([PR #20255](https://github.com/dotnet/fsharp/pull/20255)) * Name resolution: group C#-style extension members per `open` and extended type ([PR #20298](https://github.com/dotnet/fsharp/pull/20298)) +* `FSharpProjectSnapshot.FromOptions` takes an optional `getReferenceStamp: string -> DateTime`, so a host that already tracks the last-write times of on-disk references can supply them instead of having every `-r:` stat'd again each time a snapshot is built. ([PR #20459](https://github.com/dotnet/fsharp/pull/20459)) ### Improved @@ -192,6 +199,7 @@ * IL: use empty tables for members when possible ([PR #20249](https://github.com/dotnet/fsharp/pull/20249)) * Make Entity's adhoc members list lazy ([PR #20286](https://github.com/dotnet/fsharp/pull/20286/changes)) * Constraint solver: `TryD` is now `inline` with `[]` on its always-run continuation, so the argument closures are no longer allocated at the (very hot) constraint-solver call sites; `IgnoreFailedMemberConstraintResolution` is `inline` so its forwarded continuation stays a literal. ([PR #20367](https://github.com/dotnet/fsharp/pull/20367)) +* `DelayedILModuleReader` no longer boxes its cached `ILModuleReader` on every read: the field is typed `ILModuleReader | null` and matched directly. ([PR #20413](https://github.com/dotnet/fsharp/pull/20413)) ### Changed * The `--warnaserror` option now ignores unrecognized diagnostic identifiers in warning lists while still applying recognized F# warning codes. ([PR #20246](https://github.com/dotnet/fsharp/pull/20246)) @@ -204,15 +212,18 @@ * Lower string-typed interpolated strings to `System.String.Concat` rather than the reflection-based `printf` engine, making them trim- and NativeAOT-compatible. This generalizes and ungates the previous all-string `String.Concat` optimization, so it now applies to every string-typed interpolation. ([Language suggestion #1108](https://github.com/fsharp/fslang-suggestions/issues/1108), [PR #19971](https://github.com/dotnet/fsharp/pull/19971)) * Stabilized several `preview` language features into F# 11.0 (`--langversion:11.0`, enabled by default with a .NET 11 SDK): `MethodOverloadsCache`, `ErrorOnMissingSignatureAttribute`, `DirectDelegateConstruction`, `AccessProtectedBaseFieldFromClosure`, and `RecordSpreads`. `FromEndSlicing` intentionally remains in `preview`. ([PR #20199](https://github.com/dotnet/fsharp/pull/20199)) * Interpolated string holes (e.g. `$"{x}"`) are now formatted with invariant culture (via the `string` operator) instead of the current thread culture. ([PR #19971](https://github.com/dotnet/fsharp/pull/19971)) +* `Md5Hasher` now reuses the thread-local `MD5` instance instead of allocating a new one per call, and hashes strings without allocating an intermediate UTF8-encoded byte array by renting a buffer from the shared `ArrayPool`. ([Issue #20204](https://github.com/dotnet/fsharp/issues/20204), [PR #20275](https://github.com/dotnet/fsharp/pull/20275)) * Lines starting with `#:` are now ignored ([Language suggestion 1440](https://github.com/fsharp/fslang-suggestions/issues/1440), [RFC FS-1337](https://github.com/fsharp/fslang-design/pull/830), [PR #20212](https://github.com/dotnet/fsharp/pull/20212)) * `[]` attributes written in front of a binding are again reported by `SynBinding.attributes` in the untyped syntax tree, with their original grouping and `[< >]` ranges. The rotation into `SynValInfo.retInfo` added by [PR #19738](https://github.com/dotnet/fsharp/pull/19738) now happens while normalizing a binding for checking instead of in the parser, so both fixes from that PR are unchanged while tools reading the parse tree (formatters, analyzers, source generators) again see what was written. ([PR #20356](https://github.com/dotnet/fsharp/pull/20356)) * Calculate Entity.PublicPath instead of storing ([PR #20285](https://github.com/dotnet/fsharp/pull/20285)) * Remove the always-on `NullableOptionalInterop` language feature flag and collapse its guarded code to the enabled path; `--disableLanguageFeature:NullableOptionalInterop` is no longer a recognized feature name. ([Issue #20154](https://github.com/dotnet/fsharp/issues/20154), [PR #20378](https://github.com/dotnet/fsharp/pull/20378)) ### Breaking Changes +* `DelayedILModuleReader`'s constructor now takes a `CancellationToken -> Stream voption` stream factory instead of `CancellationToken -> Stream option`. ([PR #20414](https://github.com/dotnet/fsharp/pull/20414)) * Add `ExtendedLayoutAttribute` support for future .NET runtime interop. `ILTypeDefLayout` has a new `Extended` case. ([Issue #19190](https://github.com/dotnet/fsharp/issues/19190), [PR #19194](https://github.com/dotnet/fsharp/pull/19194)) * `FSharp.Compiler.Syntax.SynInterpolatedStringPart.FillExpr` now carries a `SynInterpolationFormatting` value (separating .NET alignment/format from printf specifiers) instead of an `Ident option`. ([PR #19971](https://github.com/dotnet/fsharp/pull/19971)) * `FSharp.Compiler.Syntax.SynComponentInfo` now holds the type name as `synType: SynType option` instead of the previous `longId: LongIdent` field, so tuple-type extensions such as `type ('T1 * 'T2) with` can be represented. A `member LongIdent` compatibility property returns the long identifier for named types and an empty list for tuple or erroneous type names. AST consumers that pattern-matched on the `longId` field must switch to the `synType` field or the `LongIdent` member. ([PR #19602](https://github.com/dotnet/fsharp/pull/19602)) * Optimizer: don't inline named functions in debug builds ([PR #19548](https://github.com/dotnet/fsharp/pull/19548) * LexFilter: drop non-strict mode ([PR #20106](https://github.com/dotnet/fsharp/pull/20106)) +* `FSharp.Compiler.EditorServices.Structure.getOutliningRanges` now takes the source lines as `ReadOnlyMemory[]` instead of `string[]`, so a caller that already holds the whole text can slice it instead of building a string per line. Callers passing a `string[]` can migrate with `Array.map (fun line -> line.AsMemory())`. ([PR #20443](https://github.com/dotnet/fsharp/pull/20443)) diff --git a/docs/release-notes/.VisualStudio/18.vNext.md b/docs/release-notes/.VisualStudio/18.vNext.md index ef64e1a75c4..3bde9f7b251 100644 --- a/docs/release-notes/.VisualStudio/18.vNext.md +++ b/docs/release-notes/.VisualStudio/18.vNext.md @@ -2,21 +2,50 @@ * Code-fixes for FS3888 (compiler-semantic attribute on the `.fs` but not the `.fsi`): copy the attribute into the `.fsi`, or remove it from the `.fs`. ([Issue #19560](https://github.com/dotnet/fsharp/issues/19560), [PR #19880](https://github.com/dotnet/fsharp/pull/19880)) * Expand `` in IDE tooltips, completion, and signature help, inheriting XML documentation from base classes, interfaces, overridden members, and constructors. ([Issue #19175](https://github.com/dotnet/fsharp/issues/19175), [PR #19188](https://github.com/dotnet/fsharp/pull/19188)) +* F# types, modules, members and values now appear in the GitHub Copilot Chat `#` mention picker, and attach their declaration source as context. ([PR #20409](https://github.com/dotnet/fsharp/pull/20409)) +* Find All References on an F# symbol also lists its uses in C# and Visual Basic projects that reference the F# project's built assembly. ([PR #20463](https://github.com/dotnet/fsharp/pull/20463)) ### Fixed +* Navigate To lists F# declarations while the solution is still loading. Until now the search that runs during load skipped F# entirely, and the full search that follows it is never started, so nothing F# declares could be found until the next search. +* Reuse the in-memory PE reference of a referenced C# project while its dependent semantic version is unchanged, so dependent F# projects no longer re-import every reference and re-check every file each time Roslyn recreates the `Compilation`. ([PR #20460](https://github.com/dotnet/fsharp/pull/20460)) +* Fixed `MissingManifestResourceException` when opening F# pages under Tools > Options by giving `FSharpPackage` its own `VSPackage` resource set. ([PR #20408](https://github.com/dotnet/fsharp/pull/20408)) +* Go To Definition no longer blocks the UI thread with a bare `Task.Wait`: the synchronous `IFSharpGoToDefinitionService` call now waits through the cancellable threaded-wait dialog, and the editor's `TaskCompletionSource` bridges run their continuations on the thread pool instead of inline on whichever thread finished the check, so repeated F12 on a large solution no longer starves semantic classification and other main-thread work. +* Go To Definition no longer blocks the UI thread with a bare `Task.Wait`: the synchronous `IFSharpGoToDefinitionService` call now waits through the cancellable threaded-wait dialog, and the editor's `TaskCompletionSource` bridges run their continuations on the thread pool instead of inline on whichever thread finished the check, so repeated F12 on a large solution no longer starves semantic classification and other main-thread work. ([PR #20482](https://github.com/dotnet/fsharp/pull/20482)) * Improve Find All References performance by throttling parallel typechecks. ([PR #20128](https://github.com/dotnet/fsharp/pull/20128)) * Fixed Rename incorrectly renaming `get` and `set` keywords for properties with explicit accessors. ([Issue #18270](https://github.com/dotnet/fsharp/issues/18270), [PR #19252](https://github.com/dotnet/fsharp/pull/19252)) * Fixed Find All References crash when F# project contains non-F# files like `.cshtml`. ([Issue #16394](https://github.com/dotnet/fsharp/issues/16394), [PR #19252](https://github.com/dotnet/fsharp/pull/19252)) * Avoid using `cancellableTask` in `DocumentCache`; the editor cache now uses direct `CancellationToken`-aware `task` wrappers, avoiding the background `Task.Run` offload and a larger wrapper closure from the `cancellableTask` builder. ([Issue #20268](https://github.com/dotnet/fsharp/issues/20268)) +* Prevent stale and leaked per-document diagnostics by tightening cache validity and evicting entries for removed documents. ([PR #20121](https://github.com/dotnet/fsharp/pull/20121)) +* Avoid blocking the VS project-options mailbox and unnecessary script option recomputation while editing by making invalidation async and debouncing caret-driven updates. ([Issue #20124](https://github.com/dotnet/fsharp/issues/20124), [Issue #20125](https://github.com/dotnet/fsharp/issues/20125), [PR #20126](https://github.com/dotnet/fsharp/pull/20126)) +* Cache document diagnostics by version stamp, so an unchanged document is not reanalyzed on every crawler pass. ([Issue #20120](https://github.com/dotnet/fsharp/issues/20120), [PR #20121](https://github.com/dotnet/fsharp/pull/20121)) +* Transparent compiler snapshot reuse no longer stats every `-r:` reference on each project change; reference timestamps are cached and invalidated by `IVsAsyncFileChangeEx2` notifications. ([PR #20457](https://github.com/dotnet/fsharp/pull/20457)) * Find All References for external DLL symbols now only searches projects that reference the specific assembly. ([Issue #10227](https://github.com/dotnet/fsharp/issues/10227), [PR #19252](https://github.com/dotnet/fsharp/pull/19252)) * Improve static compilation of state machines. ([PR #19297](https://github.com/dotnet/fsharp/pull/19297)) * Make Alt+F1 (momentary toggle) work for inlay hints. ([PR #19421](https://github.com/dotnet/fsharp/pull/19421)) * Fix doubled F# diagnostics in tooltips. ([Issue #16360](https://github.com/dotnet/fsharp/issues/16360)) * Fix `NotSupportedException` in the memory-mapped-file optimization when copying `ReadOnlyMemory` into `MemoryMappedFileViewStream`. ([Issue #20263](https://github.com/dotnet/fsharp/issues/20263)) +* Reduce allocations in the VS project options reactor: the command-line options and project options caches and the mailbox reply payloads now hold struct tuples, and `IProjectSite.CompilationBinOutputPath` returns `string voption` picked with a new `Array.tryPickV`. ([PR #20413](https://github.com/dotnet/fsharp/pull/20413)) +* Fix `NullReferenceException` that broke syntax coloring for the rest of the file when an unlinked placeholder entity was encountered while editing broken code. ([Issue #20269](https://github.com/dotnet/fsharp/issues/20269), [PR #20274](https://github.com/dotnet/fsharp/pull/20274)) +* Fix semantic colours of open documents disappearing: their classification was stored in the unopened-documents cache and only covered the requested span, so scrolling re-ran the checker or got nothing back, and a check that could not complete answered with no classifications, which clears the colours already shown. ([Issue #20445](https://github.com/dotnet/fsharp/issues/20445), [PR #20450](https://github.com/dotnet/fsharp/pull/20450)) +* Requests for one version of an open document share a single whole-file semantic classification, kept for the life of the document instead of two seconds, so scrolling a large file after a pause no longer re-walks every symbol in it. ([PR #20452](https://github.com/dotnet/fsharp/pull/20452)) +* Keep the collapsed regions of a file while its project is loading or reloading: outlining now parses with the quick parsing options instead of answering with no regions, which expanded everything the user had folded. ([PR #20451](https://github.com/dotnet/fsharp/pull/20451)) +* Fix syntax coloring being lost for a whole file when one symbol resolves into metadata that could not be read. ([Issue #20269](https://github.com/dotnet/fsharp/issues/20269), [PR #20274](https://github.com/dotnet/fsharp/pull/20274)) +* Go To Definition on a symbol declared in another project of the solution no longer opens a generated signature when the origin document comes from a solution snapshot that predates that project's documents: navigation, Find All References and Rename now look the target up in the workspace's current solution as well. ([PR #20462](https://github.com/dotnet/fsharp/pull/20462)) +* Find All References and Rename search each file of a multi-targeted F# project once instead of once per target framework, revisiting a file in another instance only when it is compiled there alone or under conditional compilation; searches start as each project's snapshot is ready and type checks are bounded across the solution. ([PR #20464](https://github.com/dotnet/fsharp/pull/20464)) +* Go To Definition from C# or Visual Basic into an F# project no longer type checks the whole project: the file declaring the symbol is found from parsed declarations and checked alone, with the whole-project check as a fallback. ([PR #20465](https://github.com/dotnet/fsharp/pull/20465)) +* Go To Definition into an F# project built with a path map (`DeterministicSourcePaths` or `PathMap`) opens its source instead of a generated signature: the IDE no longer applies `--pathmap` to the project options it checks with. ([PR #20470](https://github.com/dotnet/fsharp/pull/20470)) +* Find Implementations no longer searches the whole solution for uses it then discards; it reports the declarations alone. +* Find All References reports the uses of a document in one go instead of starting a task per reference, stops when the request is cancelled, and shares one budget of cores with the C# and Visual Basic searches running alongside it, leaving one core to the editor. +* Go To Definition into an assembly no longer type checks the generated signature on the thread the user is typing on: the signature is written and checked in the background, and only opening the document takes the main thread. Ctrl+click and the links in a tooltip follow the same path instead of waiting for it. +* Rename no longer reads a closed file from disk on the main thread when Visual Studio asks for the span of one of its results. +* Go To Definition on a symbol imported from a project with signature files navigates to the implementation without checking that file again when the symbol already carries its range. +* Go To Definition on a C# or Visual Basic symbol asks the compilation for it by documentation comment id instead of walking every declaration the project holds, which it did on each navigation and each Ctrl+hover; the walk remains for the symbols whose id Roslyn cannot parse. +* Go To Definition from C# or Visual Basic into a multi-targeted F# project checks the whole project at most once instead of once per target framework when the parsed declarations do not answer. ### Changed * Rename "inline hints" to "inlay hints" in VS options for consistency with industry terminology. ([PR #19318](https://github.com/dotnet/fsharp/pull/19318)) * Unused analyzers: disable in VS when file has errors ([PR #19892](https://github.com/dotnet/fsharp/pull/19892)) * Move to Roslyn's unified ExternalAccess library ([PR #20099](https://github.com/dotnet/fsharp/pull/20099)) +* Reduce editor allocations when computing block structure (code folding) by slicing the source text once per outlining pass instead of copying every line. ([PR #20443](https://github.com/dotnet/fsharp/pull/20443)) diff --git a/eng/Packages.props b/eng/Packages.props index c6b2eabb7a2..a9622277045 100644 --- a/eng/Packages.props +++ b/eng/Packages.props @@ -36,6 +36,7 @@ + @@ -64,6 +65,9 @@ ComponentModelHost would otherwise stay at 17.x; that 17.x/18.x split makes S/IComponentModel ambiguous (CS0433). Pin to the SDK 18.9.496 version so those types resolve to a single assembly. --> + + diff --git a/src/Compiler/Checking/Expressions/CheckSequenceExpressions.fs b/src/Compiler/Checking/Expressions/CheckSequenceExpressions.fs index 89c5afea9c6..1e4f6214d6e 100644 --- a/src/Compiler/Checking/Expressions/CheckSequenceExpressions.fs +++ b/src/Compiler/Checking/Expressions/CheckSequenceExpressions.fs @@ -412,45 +412,46 @@ let TcSequenceExpression (cenv: TcFileState) env tpenv comp (overallTy: OverallT resExpr, tpenv and tcSequenceExprBodyAsSequenceOrStatement env genOuterTy tpenv comp = - match tryTcSequenceExprBody env genOuterTy tpenv comp with - | Some(expr, tpenv) -> Choice1Of2 expr, tpenv - | None -> - - let env = - { env with - eContextInfo = ContextInfo.SequenceExpression genOuterTy - } - - if enableImplicitYield then - // The body is speculatively type-checked once to classify it as a statement or a yielded - // element. Reporting is buffered so the kept interpretation reports exactly once (else - // format-specifier locations and diagnostics double - #16419): a unit statement keeps the - // probe result and its buffered reporting is committed; a yielded element drops it and - // TcExprFlex re-checks with the element's target type. - let hasTypeUnit, _ty, expr, tpenv = - RunWithBufferedReporting - cenv.tcSink - "SeqImplicitYieldProbe" - (fun () -> TryTcStmt cenv env tpenv comp) - (fun (hasTypeUnit, _, _, _) -> hasTypeUnit) - - if hasTypeUnit then - Choice2Of2 expr, tpenv + cenv.stackGuard.Guard(fun () -> + match tryTcSequenceExprBody env genOuterTy tpenv comp with + | Some(expr, tpenv) -> Choice1Of2 expr, tpenv + | None -> + + let env = + { env with + eContextInfo = ContextInfo.SequenceExpression genOuterTy + } + + if enableImplicitYield then + // The body is speculatively type-checked once to classify it as a statement or a yielded + // element. Reporting is buffered so the kept interpretation reports exactly once (else + // format-specifier locations and diagnostics double - #16419): a unit statement keeps the + // probe result and its buffered reporting is committed; a yielded element drops it and + // TcExprFlex re-checks with the element's target type. + let hasTypeUnit, _ty, expr, tpenv = + RunWithBufferedReporting + cenv.tcSink + "SeqImplicitYieldProbe" + (fun () -> TryTcStmt cenv env tpenv comp) + (fun (hasTypeUnit, _, _, _) -> hasTypeUnit) + + if hasTypeUnit then + Choice2Of2 expr, tpenv + else + let genResultTy = NewInferenceType g + let mExpr = expr.Range + UnifyTypes cenv env mExpr genOuterTy (mkSeqTy cenv.g genResultTy) + let expr, tpenv = TcExprFlex cenv flex true genResultTy env tpenv comp + let exprTy = tyOfExpr cenv.g expr + AddCxTypeMustSubsumeType env.eContextInfo env.DisplayEnv cenv.css mExpr NoTrace genResultTy exprTy + + let resExpr = + mkCallSeqSingleton cenv.g mExpr genResultTy (mkCoerceExpr (expr, genResultTy, mExpr, exprTy)) + + Choice1Of2 resExpr, tpenv else - let genResultTy = NewInferenceType g - let mExpr = expr.Range - UnifyTypes cenv env mExpr genOuterTy (mkSeqTy cenv.g genResultTy) - let expr, tpenv = TcExprFlex cenv flex true genResultTy env tpenv comp - let exprTy = tyOfExpr cenv.g expr - AddCxTypeMustSubsumeType env.eContextInfo env.DisplayEnv cenv.css mExpr NoTrace genResultTy exprTy - - let resExpr = - mkCallSeqSingleton cenv.g mExpr genResultTy (mkCoerceExpr (expr, genResultTy, mExpr, exprTy)) - - Choice1Of2 resExpr, tpenv - else - let stmt, tpenv = TcStmtThatCantBeCtorBody cenv env tpenv comp - Choice2Of2 stmt, tpenv + let stmt, tpenv = TcStmtThatCantBeCtorBody cenv env tpenv comp + Choice2Of2 stmt, tpenv) let coreExpr, tpenv = tcSequenceExprBody env overallTy.Commit tpenv comp let delayedExpr = mkSeqDelayedExpr coreExpr.Range coreExpr diff --git a/src/Compiler/Checking/PostInferenceChecks.fs b/src/Compiler/Checking/PostInferenceChecks.fs index 36427480f04..d1d93175a07 100644 --- a/src/Compiler/Checking/PostInferenceChecks.fs +++ b/src/Compiler/Checking/PostInferenceChecks.fs @@ -761,7 +761,7 @@ let (|OptionalCoerce|) expr = let CheckNoReraise cenv freesOpt (body: Expr) = if cenv.reportErrors then // Avoid recomputing the free variables - let fvs = match freesOpt with None -> freeInExpr CollectLocals body | Some fvs -> fvs + let fvs = match freesOpt with None -> freeInExpr (CollectLocalsWithStackGuard()) body | Some fvs -> fvs if fvs.UsesUnboundRethrow then errorR(Error(FSComp.SR.chkErrorContainsCallToRethrow(), body.Range)) diff --git a/src/Compiler/Driver/FxResolver.fs b/src/Compiler/Driver/FxResolver.fs index 0341d8fe0c8..e82b503255b 100644 --- a/src/Compiler/Driver/FxResolver.fs +++ b/src/Compiler/Driver/FxResolver.fs @@ -96,12 +96,7 @@ type internal FxResolver raise (TimeoutException(sprintf "Timeout executing command '%s' '%s'" psi.FileName psi.Arguments)) else p.WaitForExit() -#if DEBUG - if workingDir.IsSome then - FileSystem.OpenFileForWriteShim(Path.Combine(workingDir.Value, "StandardOutput.txt")).WriteAllLines(outputList) - FileSystem.OpenFileForWriteShim(Path.Combine(workingDir.Value, "StandardError.txt")).WriteAllLines(errorsList) -#endif p.ExitCode, outputList.ToArray(), errorsList.ToArray() else -1, Array.empty, Array.empty diff --git a/src/Compiler/Facilities/Hashing.fs b/src/Compiler/Facilities/Hashing.fs index d67f879ef14..89dd6af9833 100644 --- a/src/Compiler/Facilities/Hashing.fs +++ b/src/Compiler/Facilities/Hashing.fs @@ -1,6 +1,7 @@ namespace Internal.Utilities.Hashing open System +open System.Security.Cryptography open System.Threading /// Tools for hashing things with MD5 into a string that can be used as a cache key. @@ -43,19 +44,46 @@ module internal Md5StringHasher = module internal Md5Hasher = - let private md5 = - new ThreadLocal<_>(fun () -> System.Security.Cryptography.MD5.Create()) +#if NETSTANDARD2_0 + let private md5 = new ThreadLocal<_>(fun () -> MD5.Create()) - let computeHash (bytes: byte array) = - // md5.Value.ComputeHash(bytes) TODO: the threadlocal is not working in new VS extension - ignore md5 - let md5 = System.Security.Cryptography.MD5.Create() - md5.ComputeHash(bytes) + let computeHash (bytes: byte array) = md5.Value.ComputeHash(bytes) +#else + let computeHash (bytes: byte array) = MD5.HashData(bytes) +#endif let empty = Array.empty - let hashString (s: string) = - s |> System.Text.Encoding.UTF8.GetBytes |> computeHash + /// Computes the MD5 hash of a string directly into a caller-allocated 16-byte buffer, + /// avoiding the extra allocation of an intermediate hash-result array. + /// The UTF8 encoding buffer is rented from the shared ArrayPool to avoid allocating + /// a byte array the size of the input string on every call. + let hashStringInto (s: string) (destination: Span) = + let encoding = System.Text.Encoding.UTF8 + let byteCount = encoding.GetByteCount(s) + let rented = System.Buffers.ArrayPool.Shared.Rent(byteCount) + + try + encoding.GetBytes(s, 0, s.Length, rented, 0) |> ignore +#if NETSTANDARD2_0 + let hash = md5.Value.ComputeHash(rented, 0, byteCount) + hash.CopyTo(destination) +#else + let mutable bytesWritten = 0 + + MD5.TryHashData(ReadOnlySpan(rented, 0, byteCount), destination, &bytesWritten) + |> ignore +#endif + finally + System.Buffers.ArrayPool.Shared.Return(rented) + + /// Computes the MD5 hash of a string and returns it as a hex string (matching the format of + /// `toString`), without allocating an intermediate byte array for the UTF8-encoded input + /// (only the 16-byte hash result is allocated). + let hashStringToString (s: string) = + let bytes = Array.zeroCreate 16 + hashStringInto s (Span bytes) + BitConverter.ToString(bytes) let addBytes (bytes: byte array) (s: byte array) = diff --git a/src/Compiler/Facilities/Hashing.fsi b/src/Compiler/Facilities/Hashing.fsi index c154fd66078..0f4bafbb867 100644 --- a/src/Compiler/Facilities/Hashing.fsi +++ b/src/Compiler/Facilities/Hashing.fsi @@ -1,5 +1,7 @@ namespace Internal.Utilities.Hashing +open System + /// Tools for hashing things with MD5 into a string that can be used as a cache key. module internal Md5StringHasher = @@ -25,7 +27,13 @@ module internal Md5Hasher = val empty: 'a array - val hashString: s: string -> byte array + /// Computes the MD5 hash of a string directly into a caller-allocated 16-byte buffer, + /// avoiding the extra allocation of an intermediate hash-result array. + val hashStringInto: s: string -> destination: Span -> unit + + /// Computes the MD5 hash of a string and returns it as a hex string, without allocating + /// an intermediate byte array for the UTF8-encoded input (only the 16-byte hash result is allocated). + val hashStringToString: s: string -> string val addBytes: bytes: byte array -> s: byte array -> byte array diff --git a/src/Compiler/Facilities/prim-lexing.fs b/src/Compiler/Facilities/prim-lexing.fs index 21b93b12880..f6f4967ecef 100644 --- a/src/Compiler/Facilities/prim-lexing.fs +++ b/src/Compiler/Facilities/prim-lexing.fs @@ -7,8 +7,8 @@ namespace FSharp.Compiler.Text open System open System.IO open System.Collections.Immutable +open System.Runtime.InteropServices open Internal.Utilities.Library - open Internal.Utilities.Hashing type ISourceText = @@ -154,9 +154,9 @@ type StringText(str: string) = sb.Append(lastLine.Substring(0, range.EndColumn)).ToString() member _.GetChecksum() = - str - |> Md5Hasher.hashString - |> fun byteArray -> ImmutableArray.Create(byteArray, 0, byteArray.Length) + let bytes = Array.zeroCreate 16 + Md5Hasher.hashStringInto str (Span bytes) + ImmutableCollectionsMarshal.AsImmutableArray(bytes) module SourceText = @@ -190,10 +190,9 @@ module SourceTextNew = member _.GetSubTextFromRange(x) = sourceText.GetSubTextFromRange(x) member _.GetChecksum() = - // TODO: something better... - !!sourceText.ToString() - |> Md5Hasher.hashString - |> fun byteArray -> ImmutableArray.Create(byteArray, 0, byteArray.Length) + let bytes = Array.zeroCreate 16 + Md5Hasher.hashStringInto (!!sourceText.ToString()) (Span bytes) + ImmutableCollectionsMarshal.AsImmutableArray(bytes) } // NOTE: the code in this file is a drop-in replacement runtime for Lexing.fs from the FsLexYacc repository diff --git a/src/Compiler/Service/BackgroundCompiler.fs b/src/Compiler/Service/BackgroundCompiler.fs index 50f9881260f..972f02a5c17 100644 --- a/src/Compiler/Service/BackgroundCompiler.fs +++ b/src/Compiler/Service/BackgroundCompiler.fs @@ -314,7 +314,7 @@ type internal BackgroundCompiler let! ilReaderOpt = delayedReader.TryGetILModuleReader() match ilReaderOpt with - | Some ilReader -> + | ValueSome ilReader -> let ilModuleDef, ilAsmRefs = ilReader.ILModuleDef, ilReader.ILAssemblyRefs let data = RawFSharpAssemblyData(ilModuleDef, ilAsmRefs) :> IRawFSharpAssemblyData return ProjectAssemblyDataResult.Available data diff --git a/src/Compiler/Service/FSharpCheckerResults.fs b/src/Compiler/Service/FSharpCheckerResults.fs index 155d1b5f702..5235319683d 100644 --- a/src/Compiler/Service/FSharpCheckerResults.fs +++ b/src/Compiler/Service/FSharpCheckerResults.fs @@ -68,22 +68,22 @@ type DocumentSource = type DelayedILModuleReader = val private name: string val private gate: obj - val mutable private getStream: (CancellationToken -> Stream option) - val mutable private result: ILModuleReader + val mutable private getStream: (CancellationToken -> Stream voption) + val mutable private result: ILModuleReader | null new(name, getStream) = { name = name gate = obj () getStream = getStream - result = Unchecked.defaultof<_> + result = null } member this.OutputFile = this.name member this.TryGetILModuleReader() = // fast path - match box this.result with + match this.result with | null -> cancellable { let! ct = Cancellable.token () @@ -91,13 +91,11 @@ type DelayedILModuleReader = return lock this.gate (fun () -> // see if we have a result or not after the lock so we do not evaluate the stream more than once - match box this.result with + match this.result with | null -> try - let streamOpt = this.getStream ct - - match streamOpt with - | Some stream -> + match this.getStream ct with + | ValueSome stream -> let ilReaderOptions: ILReaderOptions = { pdbDirPath = None @@ -109,14 +107,14 @@ type DelayedILModuleReader = let ilReader = OpenILModuleReaderFromStream this.name stream ilReaderOptions this.result <- ilReader this.getStream <- Unchecked.defaultof<_> // clear out the function so we do not hold onto anything - Some ilReader - | _ -> None + ValueSome ilReader + | _ -> ValueNone with ex -> Trace.TraceInformation("FCS: Unable to get an ILModuleReader: {0}", ex) - None - | _ -> Some this.result) + ValueNone + | result -> ValueSome result) } - | _ -> cancellable.Return(Some this.result) + | result -> cancellable.Return(ValueSome result) [] type FSharpReferencedProject = diff --git a/src/Compiler/Service/FSharpCheckerResults.fsi b/src/Compiler/Service/FSharpCheckerResults.fsi index b1b5f78f675..cccfe8373d8 100644 --- a/src/Compiler/Service/FSharpCheckerResults.fsi +++ b/src/Compiler/Service/FSharpCheckerResults.fsi @@ -36,13 +36,13 @@ type DocumentSource = [] type DelayedILModuleReader = - new: name: string * getStream: (CancellationToken -> Stream option) -> DelayedILModuleReader + new: name: string * getStream: (CancellationToken -> Stream voption) -> DelayedILModuleReader member OutputFile: string /// Will lazily create the ILModuleReader. /// Is only evaluated once and can be called by multiple threads. - member internal TryGetILModuleReader: unit -> Cancellable + member internal TryGetILModuleReader: unit -> Cancellable /// Unused in this API type public FSharpUnresolvedReferencesSet = internal FSharpUnresolvedReferencesSet of UnresolvedAssemblyReference list diff --git a/src/Compiler/Service/FSharpProjectSnapshot.fs b/src/Compiler/Service/FSharpProjectSnapshot.fs index 02c368e7f46..dbbff3e501c 100644 --- a/src/Compiler/Service/FSharpProjectSnapshot.fs +++ b/src/Compiler/Service/FSharpProjectSnapshot.fs @@ -72,11 +72,7 @@ type FSharpFileSnapshot(FileName: string, Version: string, GetSource: unit -> Ta FSharpFileSnapshot(fileName, version, getSource) static member CreateFromString(filename: string, content: string) = - FSharpFileSnapshot( - filename, - Md5Hasher.hashString content |> Md5Hasher.toString, - fun () -> Task.FromResult(SourceTextNew.ofString content) - ) + FSharpFileSnapshot(filename, Md5Hasher.hashStringToString content, fun () -> Task.FromResult(SourceTextNew.ofString content)) static member CreateFromFileSystem(fileName: string) = FSharpFileSnapshot( @@ -647,8 +643,11 @@ and [] FSha ProjectSnapshotBase(projectConfig, referencedProjects, sourceFiles) |> FSharpProjectSnapshot - static member FromOptions(options: FSharpProjectOptions, getFileSnapshot, ?snapshotAccumulator) = + static member FromOptions + (options: FSharpProjectOptions, getFileSnapshot, ?snapshotAccumulator, ?getReferenceStamp: string -> DateTime) + = let snapshotAccumulator = defaultArg snapshotAccumulator (Dictionary()) + let getReferenceStamp = defaultArg getReferenceStamp FileSystem.GetLastWriteTimeShim async { @@ -665,7 +664,8 @@ and [] FSha |> Seq.map (function | FSharpReferencedProject.FSharpReference(outputName, options) -> async { - let! snapshot = FSharpProjectSnapshot.FromOptions(options, getFileSnapshot, snapshotAccumulator) + let! snapshot = + FSharpProjectSnapshot.FromOptions(options, getFileSnapshot, snapshotAccumulator, getReferenceStamp) return FSharpReferencedProjectSnapshot.FSharpReference(outputName, snapshot) } @@ -686,7 +686,7 @@ and [] FSha { Path = path - LastModified = FileSystem.GetLastWriteTimeShim(path) + LastModified = getReferenceStamp path }) ) diff --git a/src/Compiler/Service/IncrementalBuild.fs b/src/Compiler/Service/IncrementalBuild.fs index f3dc5086fe4..9f8effbcd7f 100644 --- a/src/Compiler/Service/IncrementalBuild.fs +++ b/src/Compiler/Service/IncrementalBuild.fs @@ -557,12 +557,13 @@ type FrameworkImportsCache(size) = let node = this.GetNode(tcConfig, frameworkDLLs, nonFrameworkResolutions) let! tcGlobals, frameworkTcImports = node.GetOrComputeValue() - // If the tcGlobals was loaded from a different project, langVersion and realsig may be different - // for each cached project. So here we create a new tcGlobals, with the existing framework values - // and updated realsig and langversion + // If the tcGlobals was loaded from a different project, langVersion, realsig and pathMap may be + // different for each cached project. So here we create a new tcGlobals, with the existing framework + // values and the updated realsig, langversion and pathMap let tcGlobals = if tcGlobals.langVersion <> tcConfig.langVersion - || tcGlobals.realsig <> tcConfig.realsig then + || tcGlobals.realsig <> tcConfig.realsig + || tcGlobals.pathMap <> tcConfig.pathMap then TcGlobals( tcGlobals.compilingFSharpCore, tcGlobals.ilg, @@ -574,7 +575,7 @@ type FrameworkImportsCache(size) = tcGlobals.tryFindSysTypeCcuHelper, tcGlobals.emitDebugInfoInQuotations, tcGlobals.noDebugAttributes, - tcGlobals.pathMap, + tcConfig.pathMap, tcConfig.langVersion, tcConfig.realsig, tcConfig.compilationMode diff --git a/src/Compiler/Service/SemanticClassification.fs b/src/Compiler/Service/SemanticClassification.fs index 691d85df6a3..1bf5a752e18 100644 --- a/src/Compiler/Service/SemanticClassification.fs +++ b/src/Compiler/Service/SemanticClassification.fs @@ -231,8 +231,10 @@ module TcResolutionsExtensions = let isDelegateInvokeMemberName name = name = "Invoke" || name = "BeginInvoke" || name = "EndInvoke" - resolutions - |> Array.iter (fun cnr -> + // A resolution can name a placeholder that metadata unpickling never linked; every + // reference field on it is null. Losing one token beats losing the file, which is + // what the enclosing DiagnosticsScope.Protect does. + let classifyResolution (cnr: CapturedNameResolution) = match cnr.Item, cnr.ItemOccurrence, cnr.Range with | (Item.CustomBuilder _ | Item.CustomOperation _), ItemOccurrence.Use, m -> add m SemanticClassificationType.ComputationExpression @@ -440,7 +442,13 @@ module TcResolutionsExtensions = else add m SemanticClassificationType.ReferenceType - | _, _, m -> add m SemanticClassificationType.Plaintext) + | _, _, m -> add m SemanticClassificationType.Plaintext + + for cnr in resolutions do + try + classifyResolution cnr + with RecoverableException e -> + Trace.TraceInformation $"FCS: skipped one resolution in GetSemanticClassification: '%s{e.Message}'" // Classify related symbol uses (e.g., union case testers → UnionCase). // These share ranges with the corresponding property classifications, so we intentionally add a second classification. diff --git a/src/Compiler/Service/ServiceStructure.fs b/src/Compiler/Service/ServiceStructure.fs index 99dd528eeb0..72e2ddf9943 100644 --- a/src/Compiler/Service/ServiceStructure.fs +++ b/src/Compiler/Service/ServiceStructure.fs @@ -2,6 +2,7 @@ namespace FSharp.Compiler.EditorServices +open System open Internal.Utilities.Library open FSharp.Compiler.Syntax open FSharp.Compiler.SyntaxTreeOps @@ -186,27 +187,39 @@ module Structure = } type LineNumber = int - type LineStr = string type CommentType = | SingleLine | XmlDoc + /// + /// Determines if a line is a single line or xml documentation comment. + /// + /// + /// Kept at module scope: a local recursive function capturing a -typed + /// helper as a closure field would need to instantiate + /// over it, which the CLR disallows for byref-like type arguments (FS0412). + /// + let commentTypeOf (line: ReadOnlySpan) = + if line.StartsWithOrdinal("///") then ValueSome XmlDoc + elif line.StartsWithOrdinal("//") then ValueSome SingleLine + else ValueNone + [] type CommentList = { - Lines: ResizeArray + Lines: ResizeArray Type: CommentType } - static member New ty lineStr = + static member New ty lineNum = { Type = ty - Lines = ResizeArray [ lineStr ] + Lines = ResizeArray [ lineNum ] } /// Returns outlining ranges for given parsed input. - let getOutliningRanges (sourceLines: string[]) (parsedInput: ParsedInput) = + let getOutliningRanges (sourceLines: ReadOnlyMemory[]) (parsedInput: ParsedInput) = let acc = ResizeArray() /// Validation function to ensure that ranges yielded for outlining span 2 or more lines @@ -661,7 +674,7 @@ module Structure = | r :: rest, last :: _ when r.StartLine = last.EndLine + 1 || sourceLines[last.EndLine .. r.StartLine - 2] - |> Array.forall System.String.IsNullOrWhiteSpace + |> Array.forall _.Span.IsWhiteSpace() -> loop rest res (r :: currentBulk) | r :: rest, _ -> loop rest (currentBulk :: res) [ r ] @@ -719,7 +732,7 @@ module Structure = let collectConditionalDirectives directives sourceLines = // Adds a fold region from prevRange.Start to the line above nextLine - let addSectionFold (prevRange: range) (nextLine: int) (sourceLines: string array) = + let addSectionFold (prevRange: range) (nextLine: int) (sourceLines: ReadOnlyMemory[]) = let startLineIndex = nextLine - 2 if startLineIndex >= 0 then @@ -753,7 +766,7 @@ module Structure = | ConditionalDirectiveTrivia.Else r -> ValueSome r | _ -> ValueNone - let rec group directives stack (sourceLines: string array) = + let rec group directives stack (sourceLines: ReadOnlyMemory[]) = match directives with | [] -> () | ConditionalDirectiveTrivia.If _ as ifDirective :: directives -> group directives (ifDirective :: stack) sourceLines @@ -822,36 +835,29 @@ module Structure = collectOpens decls List.iter parseDeclaration decls - /// Determine if a line is a single line or xml documentation comment - let (|Comment|_|) (line: string) = - if line.StartsWithOrdinal("///") then Some XmlDoc - elif line.StartsWithOrdinal("//") then Some SingleLine - else None - - let getCommentRanges trivia (lines: string[]) = - let rec loop (lastLineNum, currentComment, result as state) (lines: string list) lineNum = - match lines with - | [] -> state - | lineStr :: rest -> - match lineStr.TrimStart(), currentComment with - | Comment commentType, Some comment -> + let getCommentRanges trivia (lines: ReadOnlyMemory[]) = + let rec loop (struct (lastLineNum, currentComment, result) as state) lineNum = + if lineNum = lines.Length then + state + else + match commentTypeOf (lines[lineNum].Span.TrimStart()), currentComment with + | ValueSome commentType, Some comment -> loop (if comment.Type = commentType && lineNum = lastLineNum + 1 then - comment.Lines.Add(lineNum, lineStr) - lineNum, currentComment, result + comment.Lines.Add lineNum + struct (lineNum, currentComment, result) else - let comments = CommentList.New commentType (lineNum, lineStr) - lineNum, Some comments, comment :: result) - rest + let comments = CommentList.New commentType lineNum + struct (lineNum, Some comments, comment :: result)) (lineNum + 1) - | Comment commentType, None -> - let comments = CommentList.New commentType (lineNum, lineStr) - loop (lineNum, Some comments, result) rest (lineNum + 1) - | _, Some comment -> loop (lineNum, None, comment :: result) rest (lineNum + 1) - | _ -> loop (lineNum, None, result) rest (lineNum + 1) + | ValueSome commentType, None -> + let comments = CommentList.New commentType lineNum + loop (struct (lineNum, Some comments, result)) (lineNum + 1) + | ValueNone, Some comment -> loop (struct (lineNum, None, comment :: result)) (lineNum + 1) + | ValueNone, None -> loop (struct (lineNum, None, result)) (lineNum + 1) let comments = - let _, lastComment, comments = loop (-1, None, []) (List.ofArray lines) 0 + let struct (_, lastComment, comments) = loop (struct (-1, None, [])) 0 match lastComment with | Some comment -> comment :: comments @@ -859,13 +865,12 @@ module Structure = |> List.rev comments - |> List.filter (fun comment -> comment.Lines.Count > 1) - |> List.map (fun comment -> - let lines = comment.Lines - let startLine, startStr = lines[0] - let endLine, endStr = lines[lines.Count - 1] - let startCol = startStr.IndexOf '/' - let endCol = endStr.TrimEnd().Length + |> Seq.filter (fun comment -> comment.Lines.Count > 1) + |> Seq.map (fun comment -> + let startLine = comment.Lines[0] + let endLine = comment.Lines[comment.Lines.Count - 1] + let startCol = lines[startLine].Span.IndexOf '/' + let endCol = lines[endLine].Span.TrimEnd().Length let scopeType = match comment.Type with diff --git a/src/Compiler/Service/ServiceStructure.fsi b/src/Compiler/Service/ServiceStructure.fsi index 87711629676..3695e7148ac 100644 --- a/src/Compiler/Service/ServiceStructure.fsi +++ b/src/Compiler/Service/ServiceStructure.fsi @@ -2,6 +2,7 @@ namespace FSharp.Compiler.EditorServices +open System open FSharp.Compiler.Syntax open FSharp.Compiler.Text @@ -79,4 +80,4 @@ module public Structure = } /// Returns outlining ranges for given parsed input. - val getOutliningRanges: sourceLines: string[] -> parsedInput: ParsedInput -> seq + val getOutliningRanges: sourceLines: ReadOnlyMemory[] -> parsedInput: ParsedInput -> seq diff --git a/src/Compiler/Service/TransparentCompiler.fs b/src/Compiler/Service/TransparentCompiler.fs index 691dde3e802..86a88fa0c1f 100644 --- a/src/Compiler/Service/TransparentCompiler.fs +++ b/src/Compiler/Service/TransparentCompiler.fs @@ -779,7 +779,7 @@ type internal TransparentCompiler let! ilReaderOpt = delayedReader.TryGetILModuleReader() match ilReaderOpt with - | Some ilReader -> + | ValueSome ilReader -> let ilModuleDef, ilAsmRefs = ilReader.ILModuleDef, ilReader.ILAssemblyRefs let data = RawFSharpAssemblyData(ilModuleDef, ilAsmRefs) :> IRawFSharpAssemblyData return ProjectAssemblyDataResult.Available data @@ -964,13 +964,14 @@ type internal TransparentCompiler // Prepare the frameworkTcImportsCache let! tcGlobals, frameworkTcImports = ComputeFrameworkImports tcConfig frameworkDLLs nonFrameworkResolutions - // If the tcGlobals was loaded from a different project, langVersion and realsig may be different - // for each cached project. So here we create a new tcGlobals, with the existing framework values - // and updated realsig and langversion + // If the tcGlobals was loaded from a different project, langVersion, realsig and pathMap may be + // different for each cached project. So here we create a new tcGlobals, with the existing + // framework values and the updated realsig, langversion and pathMap let tcGlobals = if tcGlobals.langVersion <> tcConfig.langVersion || tcGlobals.realsig <> tcConfig.realsig + || tcGlobals.pathMap <> tcConfig.pathMap then TcGlobals( tcGlobals.compilingFSharpCore, @@ -983,7 +984,7 @@ type internal TransparentCompiler tcGlobals.tryFindSysTypeCcuHelper, tcGlobals.emitDebugInfoInQuotations, tcGlobals.noDebugAttributes, - tcGlobals.pathMap, + tcConfig.pathMap, tcConfig.langVersion, tcConfig.realsig, tcConfig.compilationMode diff --git a/src/Compiler/TypedTree/TypedTreePickle.fs b/src/Compiler/TypedTree/TypedTreePickle.fs index 9d599189afe..0a53436e57f 100644 --- a/src/Compiler/TypedTree/TypedTreePickle.fs +++ b/src/Compiler/TypedTree/TypedTreePickle.fs @@ -2723,7 +2723,7 @@ let rec p_tycon_repr x st = let allFieldsText = fields - |> Array.map (fun f -> f.LogicalName) + |> Seq.map _.LogicalName |> String.concat System.Environment.NewLine raise (Error(FSComp.SR.pickleFsharpCoreBackwardsCompatible ("fields in union", allFieldsText), firstFieldRange)) @@ -2826,6 +2826,9 @@ and p_rfield_table x st = p_array p_recdfield_spec x.FieldsByIndex st and p_entity_spec_data (x: Entity) st = + if not x.IsLinked then + pfailwith st "p_entity_spec_data: entity was never linked during unpickling" + p_tyar_specs x.Typars st p_string x.entity_logical_name st p_option p_string x.EntityCompiledName st @@ -2882,8 +2885,9 @@ and p_tcaug p st = // in order to get check the well-formedness of an interface. // Keeping them across assembly boundaries is not valid, because relinking their ValRefs // does not work correctly (they may get incorrectly relinked to a default member) - |> List.filter (fun (isExplicitImpl, _) -> not isExplicitImpl) - |> List.map (fun (_, vref) -> vref.LogicalName, vref)), + |> Seq.filter (fun (isExplicitImpl, _) -> not isExplicitImpl) + |> Seq.map (fun (_, vref) -> vref.LogicalName, vref) + |> Seq.toList), p.tcaug_interfaces, p.tcaug_super, p.tcaug_abstract, diff --git a/src/Compiler/Utilities/illib.fs b/src/Compiler/Utilities/illib.fs index 5842492b31d..a4c597f01f6 100644 --- a/src/Compiler/Utilities/illib.fs +++ b/src/Compiler/Utilities/illib.fs @@ -7,6 +7,7 @@ open System.Collections.Generic open System.Collections.Concurrent open System.Diagnostics open System.IO +open System.Linq open System.Threading open System.Threading.Tasks open System.Runtime.CompilerServices @@ -97,6 +98,9 @@ module internal PervasiveAutoOpens = member inline x.StartsWithOrdinal(value: string) = x.StartsWith(value, StringComparison.Ordinal) + member inline x.StartsWithOrdinalIgnoreCase(value: string) = + x.StartsWith(value, StringComparison.OrdinalIgnoreCase) + member inline x.EndsWithOrdinal(value: string) = x.EndsWith(value, StringComparison.Ordinal) @@ -112,6 +116,63 @@ module internal PervasiveAutoOpens = member inline x.IndexOfOrdinal(value: string, startIndex, count) = x.IndexOf(value, startIndex, count, StringComparison.Ordinal) + [] + type ReadOnlySpanCharExtensions = + + static member inline StartsWithOrdinal(str: ReadOnlySpan, value: ReadOnlySpan) = + str.StartsWith(value, StringComparison.Ordinal) + + static member inline StartsWithOrdinal(str: ReadOnlySpan, value: string) = + str.StartsWith(value.AsSpan(), StringComparison.Ordinal) + + static member inline EndsWithOrdinal(str: ReadOnlySpan, value: ReadOnlySpan) = + str.EndsWith(value, StringComparison.Ordinal) + + static member inline EndsWithOrdinal(str: ReadOnlySpan, value: string) = + str.EndsWith(value.AsSpan(), StringComparison.Ordinal) + + static member inline EndsWithOrdinalIgnoreCase(str: ReadOnlySpan, value: ReadOnlySpan) = + str.EndsWith(value, StringComparison.OrdinalIgnoreCase) + + static member inline EndsWithOrdinalIgnoreCase(str: ReadOnlySpan, value: string) = + str.EndsWith(value.AsSpan(), StringComparison.OrdinalIgnoreCase) + + static member IndexOf(str: ReadOnlySpan, value: char) = + let mutable index = -1 + let mutable i = 0 + + while i < str.Length && index = -1 do + if str[i] = value then index <- i else i <- i + 1 + + index + + static member inline IndexOfOrdinal(str: ReadOnlySpan, value: ReadOnlySpan) = + str.IndexOf(value, StringComparison.Ordinal) + + static member inline IndexOfOrdinal(str: ReadOnlySpan, value: string) = + str.IndexOf(value.AsSpan(), StringComparison.Ordinal) + + // Searching a slice answers with an index into that slice, so the offset goes back on to + // report a position in `str` - what the String siblings these mirror return. A miss stays -1. + + static member inline IndexOfOrdinal(str: ReadOnlySpan, value: ReadOnlySpan, startIndex) = + let i = str.Slice(startIndex).IndexOf(value, StringComparison.Ordinal) + if i < 0 then i else i + startIndex + + static member inline IndexOfOrdinal(str: ReadOnlySpan, value: string, startIndex) = + let i = str.Slice(startIndex).IndexOf(value.AsSpan(), StringComparison.Ordinal) + if i < 0 then i else i + startIndex + + static member inline IndexOfOrdinal(str: ReadOnlySpan, value: ReadOnlySpan, startIndex, count) = + let i = str.Slice(startIndex, count).IndexOf(value, StringComparison.Ordinal) + if i < 0 then i else i + startIndex + + static member inline IndexOfOrdinal(str: ReadOnlySpan, value: string, startIndex, count) = + let i = + str.Slice(startIndex, count).IndexOf(value.AsSpan(), StringComparison.Ordinal) + + if i < 0 then i else i + startIndex + /// Get an initialization hole let getHole (r: _ ref) = match r.Value with diff --git a/src/Compiler/Utilities/illib.fsi b/src/Compiler/Utilities/illib.fsi index 7c598ffa7a2..8033c3b0177 100644 --- a/src/Compiler/Utilities/illib.fsi +++ b/src/Compiler/Utilities/illib.fsi @@ -58,6 +58,8 @@ module internal PervasiveAutoOpens = member inline StartsWithOrdinal: value: string -> bool + member inline StartsWithOrdinalIgnoreCase: value: string -> bool + member inline EndsWithOrdinal: value: string -> bool member inline EndsWithOrdinalIgnoreCase: value: string -> bool @@ -68,6 +70,67 @@ module internal PervasiveAutoOpens = member inline IndexOfOrdinal: value: string * startIndex: int * count: int -> int + [] + type ReadOnlySpanCharExtensions = + + [] + static member inline StartsWithOrdinal: str: ReadOnlySpan * value: ReadOnlySpan -> bool + + [] + static member inline StartsWithOrdinal: str: ReadOnlySpan * value: string -> bool + + [] + static member inline EndsWithOrdinal: str: ReadOnlySpan * value: ReadOnlySpan -> bool + + [] + static member inline EndsWithOrdinal: str: ReadOnlySpan * value: string -> bool + + [] + static member inline EndsWithOrdinalIgnoreCase: str: ReadOnlySpan * value: ReadOnlySpan -> bool + + [] + static member inline EndsWithOrdinalIgnoreCase: str: ReadOnlySpan * value: string -> bool + + [] + static member IndexOf: str: ReadOnlySpan * value: char -> int + + [] + static member inline IndexOfOrdinal: str: ReadOnlySpan * value: ReadOnlySpan -> int + + [] + static member inline IndexOfOrdinal: str: ReadOnlySpan * value: string -> int + + /// + /// Returns a position in , not in the slice searched, matching the + /// overloads these mirror. -1 when there is no match. + /// + [] + static member inline IndexOfOrdinal: + str: ReadOnlySpan * value: ReadOnlySpan * startIndex: int -> int + + /// + /// Returns a position in , not in the slice searched, matching the + /// overloads these mirror. -1 when there is no match. + /// + [] + static member inline IndexOfOrdinal: str: ReadOnlySpan * value: string * startIndex: int -> int + + /// + /// Returns a position in , not in the slice searched, matching the + /// overloads these mirror. -1 when there is no match. + /// + [] + static member inline IndexOfOrdinal: + str: ReadOnlySpan * value: ReadOnlySpan * startIndex: int * count: int -> int + + /// + /// Returns a position in , not in the slice searched, matching the + /// overloads these mirror. -1 when there is no match. + /// + [] + static member inline IndexOfOrdinal: + str: ReadOnlySpan * value: string * startIndex: int * count: int -> int + type Async with /// Runs the computation synchronously, always starting on the current thread. diff --git a/tests/FSharp.Compiler.ComponentTests/FSharp.Compiler.ComponentTests.fsproj b/tests/FSharp.Compiler.ComponentTests/FSharp.Compiler.ComponentTests.fsproj index cc7e109373f..1d496b7b83b 100644 --- a/tests/FSharp.Compiler.ComponentTests/FSharp.Compiler.ComponentTests.fsproj +++ b/tests/FSharp.Compiler.ComponentTests/FSharp.Compiler.ComponentTests.fsproj @@ -1,4 +1,4 @@ - + @@ -537,6 +537,7 @@ + diff --git a/tests/FSharp.Compiler.ComponentTests/FSharpChecker/PathMap.fs b/tests/FSharp.Compiler.ComponentTests/FSharpChecker/PathMap.fs new file mode 100644 index 00000000000..18e99d829ce --- /dev/null +++ b/tests/FSharp.Compiler.ComponentTests/FSharpChecker/PathMap.fs @@ -0,0 +1,57 @@ +module FSharpChecker.PathMap + +open System.IO +open System.Threading.Tasks +open Xunit +open FSharp.Test.ProjectGeneration +open FSharp.Compiler.CodeAnalysis +open FSharp.Compiler.Text + +let private checkWith (checker: FSharpChecker) (project: SyntheticProject) = + ProjectWorkflowBuilder(project, checker = checker).Yield() |> Async.Ignore + +/// The framework imports, and the TcGlobals with them, are cached per framework set; the path map of +/// the project that filled the cache must not reach the ranges a sibling exposes to its consumers. +[] +[] +[] +let ``a sibling's path map does not reach the ranges of a project without one`` (useTransparentCompiler: bool) : Task = + task { + let checker = FSharpChecker.Create(useTransparentCompiler = useTransparentCompiler) + let library = SyntheticProject.Create("Library", sourceFile "Library" []) + + let mapped = + { SyntheticProject.Create("Mapped", sourceFile "Mapped" []) with + OtherOptions = [ $"--pathmap:{Path.GetDirectoryName library.ProjectDir}=.\\" ] } + + let app = + { SyntheticProject.Create("App", sourceFile "App" [ "Library" ]) with + DependsOn = [ library ] } + + do! checkWith checker mapped + do! checkWith checker app + + let appFile = app.GetFilePath "App" + + let! _, answer = + checker.ParseAndCheckFileInProject( + appFile, + 0, + SourceText.ofString (File.ReadAllText appFile), + app.GetProjectOptions checker + ) + + let checkResults = + match answer with + | FSharpCheckFileAnswer.Succeeded checkResults -> checkResults + | FSharpCheckFileAnswer.Aborted -> failwith "the check was aborted" + + let libraryFunction = + checkResults.GetAllUsesOfAllSymbolsInFile() + |> Seq.tryFind (fun symbolUse -> symbolUse.Symbol.FullName = $"{library.Name}.ModuleLibrary.f") + |> Option.defaultWith (fun () -> + failwith + $"""ModuleLibrary.f not used; symbols: %A{checkResults.GetAllUsesOfAllSymbolsInFile() |> Seq.map _.Symbol.FullName |> Seq.distinct |> List.ofSeq}""") + + Assert.Equal(library.GetFilePath "Library", libraryFunction.Symbol.DeclarationLocation.Value.FileName) + } diff --git a/tests/FSharp.Compiler.ComponentTests/FSharpChecker/ProjectSnapshot.fs b/tests/FSharp.Compiler.ComponentTests/FSharpChecker/ProjectSnapshot.fs index 90e28cc63ac..900b8aa04b0 100644 --- a/tests/FSharp.Compiler.ComponentTests/FSharpChecker/ProjectSnapshot.fs +++ b/tests/FSharp.Compiler.ComponentTests/FSharpChecker/ProjectSnapshot.fs @@ -1,104 +1,61 @@ module FSharpChecker.ProjectSnapshot -open Xunit open System +open System.IO +open System.Threading.Tasks +open FSharp.Compiler.CodeAnalysis open FSharp.Compiler.CodeAnalysis.ProjectSnapshot +open Xunit - -// TODO: restore tests - -//[] -//let WithoutImplFilesThatHaveSignatures () = - -// let snapshot = FSharpProjectSnapshot.Create( -// projectFileName = "Dummy.fsproj", -// projectId = None, -// sourceFiles = [ -// { FileName = "A.fsi"; Version = "1"; GetSource = Unchecked.defaultof<_> } -// { FileName = "A.fs"; Version = "1"; GetSource = Unchecked.defaultof<_> } -// { FileName = "B.fs"; Version = "1"; GetSource = Unchecked.defaultof<_> } -// { FileName = "C.fsi"; Version = "1"; GetSource = Unchecked.defaultof<_> } -// { FileName = "C.fs"; Version = "1"; GetSource = Unchecked.defaultof<_> } -// ], -// referencesOnDisk = [], -// otherOptions = [], -// referencedProjects = [], -// isIncompleteTypeCheckEnvironment = true, -// useScriptResolutionRules = false, -// loadTime = DateTime(1234, 5, 6), -// unresolvedReferences = None, -// originalLoadReferences = [], -// stamp = None -// ) - -// let result = snapshot.WithoutImplFilesThatHaveSignatures - -// let expected = [| "A.fsi"; "B.fs"; "C.fsi" |] - -// Assert.Equal(expected, result.SourceFileNames |> List.toArray) - -// Assert.Equal(result.FullVersion, snapshot.SignatureVersion) - -//[] -//let WithoutImplFilesThatHaveSignaturesExceptLastOne () = - -// let snapshot = FSharpProjectSnapshot.Create( -// projectFileName = "Dummy.fsproj", -// projectId = None, -// sourceFiles = [ -// { FileName = "A.fsi"; Version = "1"; GetSource = Unchecked.defaultof<_> } -// { FileName = "A.fs"; Version = "1"; GetSource = Unchecked.defaultof<_> } -// { FileName = "B.fs"; Version = "1"; GetSource = Unchecked.defaultof<_> } -// { FileName = "C.fsi"; Version = "1"; GetSource = Unchecked.defaultof<_> } -// { FileName = "C.fs"; Version = "1"; GetSource = Unchecked.defaultof<_> } -// ], -// referencesOnDisk = [], -// otherOptions = [], -// referencedProjects = [], -// isIncompleteTypeCheckEnvironment = true, -// useScriptResolutionRules = false, -// loadTime = DateTime(1234, 5, 6), -// unresolvedReferences = None, -// originalLoadReferences = [], -// stamp = None -// ) - -// let result = snapshot.WithoutImplFilesThatHaveSignaturesExceptLastOne - -// let expected = [| "A.fsi"; "B.fs"; "C.fsi"; "C.fs" |] - -// Assert.Equal(expected, result.SourceFileNames |> List.toArray) - -// Assert.Equal(result.FullVersion, snapshot.LastFileVersion) - - -//[] -//let WithoutImplFilesThatHaveSignaturesExceptLastOne_2 () = - -// let snapshot = FSharpProjectSnapshot.Create( -// projectFileName = "Dummy.fsproj", -// projectId = None, -// sourceFiles = [ -// { FileName = "A.fs"; Version = "1"; GetSource = Unchecked.defaultof<_> } -// { FileName = "B.fs"; Version = "1"; GetSource = Unchecked.defaultof<_> } -// { FileName = "C.fs"; Version = "1"; GetSource = Unchecked.defaultof<_> } -// ], -// referencesOnDisk = [], -// otherOptions = [], -// referencedProjects = [], -// isIncompleteTypeCheckEnvironment = true, -// useScriptResolutionRules = false, -// loadTime = DateTime(1234, 5, 6), -// unresolvedReferences = None, -// originalLoadReferences = [], -// stamp = None -// ) - -// let result = snapshot.WithoutImplFilesThatHaveSignaturesExceptLastOne - -// let expected = [| "A.fs"; "B.fs"; "C.fs" |] - -// Assert.Equal(expected, result.SourceFileNames |> List.toArray) - -// Assert.Equal(result.FullVersion, snapshot.LastFileVersion) - +#nowarn "57" + +let private projectOptions projectFileName references referencedProjects = + { + ProjectFileName = projectFileName + ProjectId = None + SourceFiles = [| Path.ChangeExtension(projectFileName, ".fs") |] + OtherOptions = [| for path in references -> $"-r:{path}" |] + ReferencedProjects = referencedProjects + IsIncompleteTypeCheckEnvironment = false + UseScriptResolutionRules = false + LoadTime = DateTime.UtcNow + UnresolvedReferences = None + OriginalLoadReferences = [] + Stamp = None + } + +let private emptySource _ path = + async { return FSharpFileSnapshot.CreateFromString(path, "") } + +[] +let ``FromOptions takes reference stamps from the host, including referenced projects`` () : Task = + task { + let stamps = + dict + [ + "MainRef.dll", DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc) + "LibRef.dll", DateTime(2026, 2, 2, 0, 0, 0, DateTimeKind.Utc) + ] + + let lib = projectOptions "Lib.fsproj" [ "LibRef.dll" ] [||] + + let main = + projectOptions "Main.fsproj" [ "MainRef.dll" ] [| FSharpReferencedProject.FSharpReference("Lib.dll", lib) |] + + let! snapshot = FSharpProjectSnapshot.FromOptions(main, emptySource, getReferenceStamp = (fun path -> stamps[path])) + + let libSnapshot = + match snapshot.ReferencedProjects with + | [ FSharpReferencedProjectSnapshot.FSharpReference(_, lib) ] -> lib + | other -> failwith $"Expected one referenced project, got %A{other}" + + Assert.Equal( + [ { Path = "MainRef.dll"; LastModified = stamps["MainRef.dll"] } ], + snapshot.ReferencesOnDisk + ) + + Assert.Equal( + [ { Path = "LibRef.dll"; LastModified = stamps["LibRef.dll"] } ], + libSnapshot.ReferencesOnDisk + ) + } diff --git a/tests/FSharp.Compiler.ComponentTests/Language/SequenceExpressions/SequenceExpressionTests.fs b/tests/FSharp.Compiler.ComponentTests/Language/SequenceExpressions/SequenceExpressionTests.fs index 049971fb261..534dc156f27 100644 --- a/tests/FSharp.Compiler.ComponentTests/Language/SequenceExpressions/SequenceExpressionTests.fs +++ b/tests/FSharp.Compiler.ComponentTests/Language/SequenceExpressions/SequenceExpressionTests.fs @@ -789,4 +789,18 @@ let f1() = |> asExe |> ignoreWarnings |> compileAndRun - |> shouldSucceed \ No newline at end of file + |> shouldSucceed + + [] + let ``Long sequence of implicit yields does not overflow the stack``() = + let elements = String.concat "\n" [ for i in 1..1000 -> $" {{ Name = \"n{i}\"; Id = {i} }}" ] + FSharp $""" +module M +type Data = {{ Name: string; Id: int }} +let all = + seq {{ +{elements} + }} +""" + |> typecheck + |> shouldSucceed diff --git a/tests/FSharp.Compiler.Service.Tests/FSharp.Compiler.Service.SurfaceArea.netstandard20.bsl b/tests/FSharp.Compiler.Service.Tests/FSharp.Compiler.Service.SurfaceArea.netstandard20.bsl index 5c9c346b613..d33433ac3fb 100644 --- a/tests/FSharp.Compiler.Service.Tests/FSharp.Compiler.Service.SurfaceArea.netstandard20.bsl +++ b/tests/FSharp.Compiler.Service.Tests/FSharp.Compiler.Service.SurfaceArea.netstandard20.bsl @@ -2065,7 +2065,7 @@ FSharp.Compiler.Cancellable: Void CheckAndThrow() FSharp.Compiler.Cancellable: Void TryCheckAndThrow() FSharp.Compiler.CodeAnalysis.DelayedILModuleReader: System.String OutputFile FSharp.Compiler.CodeAnalysis.DelayedILModuleReader: System.String get_OutputFile() -FSharp.Compiler.CodeAnalysis.DelayedILModuleReader: Void .ctor(System.String, Microsoft.FSharp.Core.FSharpFunc`2[System.Threading.CancellationToken,Microsoft.FSharp.Core.FSharpOption`1[System.IO.Stream]]) +FSharp.Compiler.CodeAnalysis.DelayedILModuleReader: Void .ctor(System.String, Microsoft.FSharp.Core.FSharpFunc`2[System.Threading.CancellationToken,Microsoft.FSharp.Core.FSharpValueOption`1[System.IO.Stream]]) FSharp.Compiler.CodeAnalysis.DocumentSource+Custom: Microsoft.FSharp.Core.FSharpFunc`2[System.String,Microsoft.FSharp.Control.FSharpAsync`1[Microsoft.FSharp.Core.FSharpOption`1[FSharp.Compiler.Text.ISourceText]]] Item FSharp.Compiler.CodeAnalysis.DocumentSource+Custom: Microsoft.FSharp.Core.FSharpFunc`2[System.String,Microsoft.FSharp.Control.FSharpAsync`1[Microsoft.FSharp.Core.FSharpOption`1[FSharp.Compiler.Text.ISourceText]]] get_Item() FSharp.Compiler.CodeAnalysis.DocumentSource+Tags: Int32 Custom @@ -2502,7 +2502,7 @@ FSharp.Compiler.CodeAnalysis.ProjectSnapshot+FSharpProjectSnapshot: Microsoft.FS FSharp.Compiler.CodeAnalysis.ProjectSnapshot+FSharpProjectSnapshot: Microsoft.FSharp.Collections.FSharpList`1[System.Tuple`3[FSharp.Compiler.Text.Range,System.String,System.String]] OriginalLoadReferences FSharp.Compiler.CodeAnalysis.ProjectSnapshot+FSharpProjectSnapshot: Microsoft.FSharp.Collections.FSharpList`1[System.Tuple`3[FSharp.Compiler.Text.Range,System.String,System.String]] get_OriginalLoadReferences() FSharp.Compiler.CodeAnalysis.ProjectSnapshot+FSharpProjectSnapshot: Microsoft.FSharp.Control.FSharpAsync`1[FSharp.Compiler.CodeAnalysis.ProjectSnapshot+FSharpProjectSnapshot] FromOptions(FSharp.Compiler.CodeAnalysis.FSharpProjectOptions, FSharp.Compiler.CodeAnalysis.DocumentSource) -FSharp.Compiler.CodeAnalysis.ProjectSnapshot+FSharpProjectSnapshot: Microsoft.FSharp.Control.FSharpAsync`1[FSharp.Compiler.CodeAnalysis.ProjectSnapshot+FSharpProjectSnapshot] FromOptions(FSharp.Compiler.CodeAnalysis.FSharpProjectOptions, Microsoft.FSharp.Core.FSharpFunc`2[FSharp.Compiler.CodeAnalysis.FSharpProjectOptions,Microsoft.FSharp.Core.FSharpFunc`2[System.String,Microsoft.FSharp.Control.FSharpAsync`1[FSharp.Compiler.CodeAnalysis.ProjectSnapshot+FSharpFileSnapshot]]], Microsoft.FSharp.Core.FSharpOption`1[System.Collections.Generic.Dictionary`2[FSharp.Compiler.CodeAnalysis.FSharpProjectOptions,FSharp.Compiler.CodeAnalysis.ProjectSnapshot+FSharpProjectSnapshot]]) +FSharp.Compiler.CodeAnalysis.ProjectSnapshot+FSharpProjectSnapshot: Microsoft.FSharp.Control.FSharpAsync`1[FSharp.Compiler.CodeAnalysis.ProjectSnapshot+FSharpProjectSnapshot] FromOptions(FSharp.Compiler.CodeAnalysis.FSharpProjectOptions, Microsoft.FSharp.Core.FSharpFunc`2[FSharp.Compiler.CodeAnalysis.FSharpProjectOptions,Microsoft.FSharp.Core.FSharpFunc`2[System.String,Microsoft.FSharp.Control.FSharpAsync`1[FSharp.Compiler.CodeAnalysis.ProjectSnapshot+FSharpFileSnapshot]]], Microsoft.FSharp.Core.FSharpOption`1[System.Collections.Generic.Dictionary`2[FSharp.Compiler.CodeAnalysis.FSharpProjectOptions,FSharp.Compiler.CodeAnalysis.ProjectSnapshot+FSharpProjectSnapshot]], Microsoft.FSharp.Core.FSharpOption`1[Microsoft.FSharp.Core.FSharpFunc`2[System.String,System.DateTime]]) FSharp.Compiler.CodeAnalysis.ProjectSnapshot+FSharpProjectSnapshot: Microsoft.FSharp.Control.FSharpAsync`1[FSharp.Compiler.CodeAnalysis.ProjectSnapshot+FSharpProjectSnapshot] FromOptions(FSharp.Compiler.CodeAnalysis.FSharpProjectOptions, System.String, Int32, FSharp.Compiler.Text.ISourceText, FSharp.Compiler.CodeAnalysis.DocumentSource) FSharp.Compiler.CodeAnalysis.ProjectSnapshot+FSharpProjectSnapshot: Microsoft.FSharp.Core.FSharpOption`1[FSharp.Compiler.CodeAnalysis.FSharpUnresolvedReferencesSet] UnresolvedReferences FSharp.Compiler.CodeAnalysis.ProjectSnapshot+FSharpProjectSnapshot: Microsoft.FSharp.Core.FSharpOption`1[FSharp.Compiler.CodeAnalysis.FSharpUnresolvedReferencesSet] get_UnresolvedReferences() @@ -4744,7 +4744,7 @@ FSharp.Compiler.EditorServices.Structure+ScopeRange: Void .ctor(Scope, Collapse, FSharp.Compiler.EditorServices.Structure: FSharp.Compiler.EditorServices.Structure+Collapse FSharp.Compiler.EditorServices.Structure: FSharp.Compiler.EditorServices.Structure+Scope FSharp.Compiler.EditorServices.Structure: FSharp.Compiler.EditorServices.Structure+ScopeRange -FSharp.Compiler.EditorServices.Structure: System.Collections.Generic.IEnumerable`1[FSharp.Compiler.EditorServices.Structure+ScopeRange] getOutliningRanges(System.String[], FSharp.Compiler.Syntax.ParsedInput) +FSharp.Compiler.EditorServices.Structure: System.Collections.Generic.IEnumerable`1[FSharp.Compiler.EditorServices.Structure+ScopeRange] getOutliningRanges(System.ReadOnlyMemory`1[System.Char][], FSharp.Compiler.Syntax.ParsedInput) FSharp.Compiler.EditorServices.ToolTipElement+CompositionError: System.String errorText FSharp.Compiler.EditorServices.ToolTipElement+CompositionError: System.String get_errorText() FSharp.Compiler.EditorServices.ToolTipElement+Group: Microsoft.FSharp.Collections.FSharpList`1[FSharp.Compiler.EditorServices.ToolTipElementData] elements diff --git a/tests/FSharp.Compiler.Service.Tests/FSharp.Compiler.Service.Tests.fsproj b/tests/FSharp.Compiler.Service.Tests/FSharp.Compiler.Service.Tests.fsproj index e043d8554ad..4cbc017c3be 100644 --- a/tests/FSharp.Compiler.Service.Tests/FSharp.Compiler.Service.Tests.fsproj +++ b/tests/FSharp.Compiler.Service.Tests/FSharp.Compiler.Service.Tests.fsproj @@ -66,6 +66,7 @@ + @@ -223,4 +224,12 @@ + + + + + diff --git a/tests/FSharp.Compiler.Service.Tests/StructureTests.fs b/tests/FSharp.Compiler.Service.Tests/StructureTests.fs index c0ae0d3fdff..322a0fa3bda 100644 --- a/tests/FSharp.Compiler.Service.Tests/StructureTests.fs +++ b/tests/FSharp.Compiler.Service.Tests/StructureTests.fs @@ -1,5 +1,6 @@ module FSharp.Compiler.Service.Tests.StructureTests +open System open System.IO open Xunit open FSharp.Compiler.EditorServices.Structure @@ -35,7 +36,7 @@ let (=>) (source: string) (expectedRanges: (Range * Range) list) = let ast = parseSourceCode(fileName, source) try let actual = - getOutliningRanges lines ast + getOutliningRanges (lines |> Array.map _.AsMemory()) ast |> Seq.filter (fun sr -> sr.Range.StartLine <> sr.Range.EndLine) |> Seq.map (fun sr -> getRange sr.Range, getRange sr.CollapseRange) |> Seq.sort @@ -151,7 +152,7 @@ module MyModule = // 2 type Color = // 7 { Red: int Green: int - Blue: int + Blue: int } interface IDisposable with // 13 @@ -163,7 +164,7 @@ module MyModule = // 2 type RecordColor = // 19 { Red: int Green: int - Blue: int + Blue: int } interface IDisposable with // 25 @@ -189,31 +190,31 @@ module MyModule = // 2 [] let ``open statements``() = """ -open M -open N - -module M = - let x = 1 - - open M - open N - - module M = - open M - - let x = 1 - - module M = - open M - open N - let x = 1 - -open M -open N -open H - -open G -open H +open M +open N + +module M = + let x = 1 + + open M + open N + + module M = + open M + + let x = 1 + + module M = + open M + open N + let x = 1 + +open M +open N +open H + +open G +open H """ => [ (2, 0, 3, 6), (2, 0, 3, 6) (5, 0, 19, 17), (5, 8, 19, 17) @@ -226,28 +227,28 @@ open H [] let ``hash directives``() = """ -#r @"a" -#r "b" - -#r "c" - -#r "d" -#r "e" -let x = 1 - -#r "f" -#r "g" -#load "x" -#r "y" - -#load "a" - "b" - "c" - -#load "a" - "b" - "c" -#r "d" +#r @"a" +#r "b" + +#r "c" + +#r "d" +#r "e" +let x = 1 + +#r "f" +#r "g" +#load "x" +#r "y" + +#load "a" + "b" + "c" + +#load "a" + "b" + "c" +#r "d" """ => [ (2, 3, 8, 6), (2, 3, 8, 6) (11, 3, 23, 6), (11, 3, 23, 6) ] @@ -325,7 +326,7 @@ seq { // 2 [] let ``list``() = """ -let _ = +let _ = [ 1; 2 3 ] """ @@ -382,7 +383,7 @@ finally // 5 let ``if - then - else``() = """ if true then - let f x = + let f x = () () else @@ -448,7 +449,7 @@ for x = 100 downto 10 do [] let ``for each``() = """ -for x in 0 .. 100 -> +for x in 0 .. 100 -> () () """ @@ -467,7 +468,7 @@ let ``tuple``() = [] let ``do!``() = """ -do! +do! printfn "allo" printfn "allo" """ @@ -477,10 +478,10 @@ do! let ``cexpr yield yield!``() = """ cexpr{ - yield! + yield! cexpr{ - yield - + yield + 10 } } @@ -659,7 +660,7 @@ let ``Abstract members`` () = type T() = abstract Foo: int - + [] abstract Foo: int diff --git a/tests/FSharp.Compiler.Service.Tests/UnlinkedEntityTests.fs b/tests/FSharp.Compiler.Service.Tests/UnlinkedEntityTests.fs new file mode 100644 index 00000000000..269d8e2bae8 --- /dev/null +++ b/tests/FSharp.Compiler.Service.Tests/UnlinkedEntityTests.fs @@ -0,0 +1,19 @@ +module FSharp.Compiler.Service.Tests.UnlinkedEntityTests + +open System + +open Xunit + +open FSharp.Compiler.TypedTree + +/// (#20269) A placeholder that unpickling never linked is not a readable entity — its +/// representation, attributes, augmentation and contents are all absent, so no stand-in for a +/// subset of them makes it safe. Resilience lives at the IDE read boundary instead. +[] +let ``An unlinked entity is not linked and not readable`` () = + let entity = Entity.NewUnlinked() + + Assert.False entity.IsLinked + + Assert.Throws(fun () -> entity.ModuleOrNamespaceType |> ignore) + |> ignore diff --git a/tests/fsharp/Compiler/Service/MultiProjectTests.fs b/tests/fsharp/Compiler/Service/MultiProjectTests.fs index 2633494f3e4..ef323dc1ab7 100644 --- a/tests/fsharp/Compiler/Service/MultiProjectTests.fs +++ b/tests/fsharp/Compiler/Service/MultiProjectTests.fs @@ -40,7 +40,7 @@ namespace CSharpTest cs.Emit(ms, cancellationToken = ct) |> ignore ms.Position <- 0L ms :> Stream - |> Some + |> ValueSome let stamp = DateTime.UtcNow let csRefProj = FSharpReferencedProject.PEReference((fun () -> stamp), DelayedILModuleReader("""Z:\csharp_test.dll""", getStream)) diff --git a/vsintegration/src/FSharp.Editor/Classification/ClassificationService.fs b/vsintegration/src/FSharp.Editor/Classification/ClassificationService.fs index 738003d5e3a..8634507303f 100644 --- a/vsintegration/src/FSharp.Editor/Classification/ClassificationService.fs +++ b/vsintegration/src/FSharp.Editor/Classification/ClassificationService.fs @@ -4,10 +4,13 @@ namespace Microsoft.VisualStudio.FSharp.Editor open System open System.Composition +open System.Collections.Concurrent open System.Collections.Generic open System.Collections.Immutable open System.Threading +open System.Threading.Tasks open System.Runtime.Caching +open System.Runtime.CompilerServices open Microsoft.CodeAnalysis open Microsoft.CodeAnalysis.Classification @@ -30,6 +33,52 @@ open Microsoft.VisualStudio.FSharp.Editor.Telemetry type SemanticClassificationData = SemanticClassificationView type SemanticClassificationLookup = IReadOnlyDictionary> +/// The whole-file semantic classification of one version of an open document. +type internal OpenDocumentClassification = + { + Version: VersionStamp + Text: SourceText + Lookup: SemanticClassificationLookup + } + +/// One classification of a document version, shared by every request that arrives while it runs. +/// The computation starts with the first waiter and is cancelled only when the last one leaves. +[] +type internal InFlightClassification(version: VersionStamp, compute: CancellationToken -> Task) = + let cts = new CancellationTokenSource() + let job = lazy (compute cts.Token) + let mutable waiters = 0 + + member _.Version = version + + member _.IsCancelled = cts.IsCancellationRequested + + member _.IsCompleted = job.IsValueCreated && job.Value.IsCompleted + + member _.Join(cancellationToken: CancellationToken) : Task = + Interlocked.Increment &waiters |> ignore + let job = job.Value + let left = ref 0 + + let leave () = + if + Interlocked.Exchange(left, 1) = 0 + && Interlocked.Decrement &waiters = 0 + && not job.IsCompleted + then + cts.Cancel() + + task { + use _ = cancellationToken.Register(fun () -> leave ()) + + try + let! _ = Task.WhenAny(job, Task.Delay(Timeout.Infinite, cancellationToken)) + cancellationToken.ThrowIfCancellationRequested() + return! job + finally + leave () + } + [)>] type internal FSharpClassificationService [] () = @@ -146,8 +195,96 @@ type internal FSharpClassificationService [] () = static let unopenedDocumentsSemanticClassificationCache = new DocumentCache("fsharp-unopened-documents-semantic-classification-cache", 5.) - static let openedDocumentsSemanticClassificationCache = - new DocumentCache("fsharp-opened-documents-semantic-classification-cache", 2.) + // The classification of an open document's latest checked version. Roslyn asks for it span by span + // for as long as that version is on screen, and replaces a span's semantic tags with whatever comes + // back - so it is kept for the life of the document rather than expiring, and when the checker + // cannot answer (project loading or reloading, a superseded check) it is re-emitted rather than + // answering "nothing", which would strip the colours the user already sees. + static let openDocumentClassifications = + ConditionalWeakTable() + + static let inFlightClassifications = + ConcurrentDictionary() + + static let remember (documentId: DocumentId) (classification: OpenDocumentClassification) = + // net472 has no ConditionalWeakTable.AddOrUpdate. + lock openDocumentClassifications (fun () -> + openDocumentClassifications.Remove documentId |> ignore + openDocumentClassifications.Add(documentId, classification)) + + // Only for the text it was computed from: the lookup names positions, so against edited text it + // would colour the wrong characters. + static let addLastGood (documentId: DocumentId) (sourceText: SourceText) (targetSpan: TextSpan) (result: List) = + match openDocumentClassifications.TryGetValue documentId with + | true, classification when classification.Text.ContentEquals sourceText -> + addSemanticClassificationByLookup sourceText targetSpan classification.Lookup result + | _ -> () + + static let addLastGoodForCurrentText (document: Document) (targetSpan: TextSpan) (result: List) = + match document.TryGetText() with + | true, sourceText -> addLastGood document.Id sourceText targetSpan result + | _ -> () + + static let classifyWholeFile (document: Document) (version: VersionStamp) (sourceText: SourceText) = + cancellableTask { + match! document.TryGetFSharpParseAndCheckResultsAsync(nameof (IFSharpClassificationService)) with + | ValueNone -> return ValueNone + | ValueSome(struct (_, checkResults)) -> + let classificationData = + checkResults.GetSemanticClassification(None, RelatedSymbolUseKind.All) + + // Every checked file resolves at least its enclosing module, so nothing here means + // the classification itself failed (SemanticClassification.fs recovers with an empty + // array). Remembering that would pin the version to no colours. + if classificationData.Length = 0 then + return ValueNone + else + let classification = + { + Version = version + Text = sourceText + Lookup = itemToSemanticClassificationLookup classificationData + } + + remember document.Id classification + return ValueSome classification + } + + // Requests for the same version that overlap - split views, the taggers above and below the + // viewport - share one classification instead of each walking the whole file. + static let classifyOpenDocument (document: Document) (version: VersionStamp) (sourceText: SourceText) = + cancellableTask { + let! cancellationToken = CancellableTask.getCancellationToken () + + let start () = + InFlightClassification(version, classifyWholeFile document version sourceText) + + let inFlight = + inFlightClassifications.AddOrUpdate( + document.Id, + (fun _ -> start ()), + fun _ running -> + if running.Version = version && not running.IsCancelled then + running + else + start () + ) + + try + return! inFlight.Join cancellationToken + finally + // A waiter that leaves early keeps the entry for those still waiting. + if inFlight.IsCompleted || inFlight.IsCancelled then + (inFlightClassifications :> ICollection>).Remove(KeyValuePair(document.Id, inFlight)) + |> ignore + } + + // Which store a document lands in is not observable from its classifications - a miss only costs + // a recheck - so tests reach them directly to tell the branches apart. + static member internal OpenDocumentClassifications = openDocumentClassifications + + static member internal UnopenedDocumentsSemanticClassificationCache = + unopenedDocumentsSemanticClassificationCache interface IFSharpClassificationService with // Do not perform classification if we don't have project options (#defines matter) @@ -252,15 +389,18 @@ type internal FSharpClassificationService [] () = use _eventDuration = TelemetryReporter.ReportSingleEventWithDuration(TelemetryEvents.AddSemanticClassifications, eventProps) - let! classificationData = document.GetFSharpSemanticClassificationAsync(nameof (FSharpClassificationService)) - - let classificationDataLookup = toSemanticClassificationLookup classificationData - do! unopenedDocumentsSemanticClassificationCache.SetAsync(document, classificationDataLookup) - addSemanticClassificationByLookup sourceText textSpan classificationDataLookup result + match! document.TryGetFSharpSemanticClassificationAsync(nameof (FSharpClassificationService)) with + | ValueNone -> () + | ValueSome classificationData -> + let classificationDataLookup = toSemanticClassificationLookup classificationData + do! unopenedDocumentsSemanticClassificationCache.SetAsync(document, classificationDataLookup) + addSemanticClassificationByLookup sourceText textSpan classificationDataLookup result else - match! openedDocumentsSemanticClassificationCache.TryGetValueAsync document with - | ValueSome classificationDataLookup -> + let! version = document.GetTextVersionAsync(cancellationToken) + + match openDocumentClassifications.TryGetValue document.Id with + | true, classification when classification.Version = version -> let eventProps: (string * obj) array = [| "context.document.project.id", document.Project.Id.Id.ToString() @@ -273,8 +413,8 @@ type internal FSharpClassificationService [] () = use _eventDuration = TelemetryReporter.ReportSingleEventWithDuration(TelemetryEvents.AddSemanticClassifications, eventProps) - addSemanticClassificationByLookup sourceText textSpan classificationDataLookup result - | ValueNone -> + addSemanticClassificationByLookup sourceText textSpan classification.Lookup result + | _ -> let eventProps: (string * obj) array = [| @@ -288,21 +428,13 @@ type internal FSharpClassificationService [] () = use _eventDuration = TelemetryReporter.ReportSingleEventWithDuration(TelemetryEvents.AddSemanticClassifications, eventProps) - let! _, checkResults = document.GetFSharpParseAndCheckResultsAsync(nameof (IFSharpClassificationService)) - - let targetRange = - RoslynHelpers.TextSpanToFSharpRange(document.FilePath, textSpan, sourceText) - - let classificationData = - checkResults.GetSemanticClassification(Some targetRange, RelatedSymbolUseKind.All) - - if classificationData.Length > 0 then - let classificationDataLookup = itemToSemanticClassificationLookup classificationData - do! unopenedDocumentsSemanticClassificationCache.SetAsync(document, classificationDataLookup) - - addSemanticClassification sourceText textSpan classificationData result + match! classifyOpenDocument document version sourceText with + | ValueSome classification -> addSemanticClassificationByLookup sourceText textSpan classification.Lookup result + | ValueNone -> addLastGood document.Id sourceText textSpan result } - |> CancellableTask.ifCanceledReturn () + // A cancellation that is not Roslyn's own (a superseded or aborted check surfaces as one) + // must not turn into an empty answer, which Roslyn would paint as "no colours". + |> CancellableTask.ifCanceledThen (fun () -> addLastGoodForCurrentText document textSpan result) |> CancellableTask.startAsTask cancellationToken // Do not perform classification if we don't have project options (#defines matter) diff --git a/vsintegration/src/FSharp.Editor/Common/CancellableTasks.fs b/vsintegration/src/FSharp.Editor/Common/CancellableTasks.fs index 7520395a084..43d823e641a 100644 --- a/vsintegration/src/FSharp.Editor/Common/CancellableTasks.fs +++ b/vsintegration/src/FSharp.Editor/Common/CancellableTasks.fs @@ -777,7 +777,9 @@ module CancellableTasks = } // try not to yield if on bg thread already - let tcs = new TaskCompletionSource<_>(TaskCreationOptions.None) + let tcs = + new TaskCompletionSource<_>(TaskCreationOptions.RunContinuationsAsynchronously) + let barrier = VolatileBarrier() let reg = @@ -1130,6 +1132,31 @@ module CancellableTasks = return! allTask } + /// Runs the work over the items with at most maxDegreeOfParallelism of them in flight. A worker + /// takes the next item when it frees up, so cancellation cancels the workers rather than a + /// pending task per item. + let forEachThrottled maxDegreeOfParallelism (work: 'T -> CancellableTask) (items: 'T seq) = + cancellableTask { + let! ct = getCancellationToken () + let items = Seq.toArray items + let mutable next = -1 + + let worker () = + backgroundTask { + let mutable index = Interlocked.Increment &next + + while index < items.Length do + ct.ThrowIfCancellationRequested() + do! work items[index] ct + index <- Interlocked.Increment &next + } + + let workers = + Array.init (min (max 1 maxDegreeOfParallelism) items.Length) (fun _ -> worker ()) + + do! (Task.WhenAll workers :> Task) + } + let inline whenAllTasks (tasks: CancellableTask seq) = cancellableTask { let! ct = getCancellationToken () @@ -1147,6 +1174,17 @@ module CancellableTasks = return results } + /// Runs the chooser over the items one at a time and stops at the first ValueSome. + let rec tryPick (chooser: 'T -> CancellableTask<'U voption>) (items: 'T list) : CancellableTask<'U voption> = + match items with + | [] -> singleton ValueNone + | item :: rest -> + cancellableTask { + match! chooser item with + | ValueSome picked -> return ValueSome picked + | ValueNone -> return! tryPick chooser rest + } + let inline ignore ([] ctask: CancellableTask<_>) = toUnit ctask /// If this CancellableTask gets canceled for another reason than the token being canceled, return the specified value. @@ -1160,6 +1198,17 @@ module CancellableTasks = return value } + /// If this CancellableTask gets canceled for another reason than the token being canceled, run the fallback instead. + let inline ifCanceledThen ([] fallback: unit -> unit) (ctask: CancellableTask) = + cancellableTask { + let! ct = getCancellationToken () + + try + return! ctask + with :? OperationCanceledException when ct.IsCancellationRequested = false -> + return fallback () + } + /// [] module MergeSourcesExtensions = diff --git a/vsintegration/src/FSharp.Editor/Common/CodeAnalysisExtensions.fs b/vsintegration/src/FSharp.Editor/Common/CodeAnalysisExtensions.fs index e0b29c8f9f1..1eaaaed3180 100644 --- a/vsintegration/src/FSharp.Editor/Common/CodeAnalysisExtensions.fs +++ b/vsintegration/src/FSharp.Editor/Common/CodeAnalysisExtensions.fs @@ -117,3 +117,32 @@ type Solution with | Some projectId -> self.TryGetDocumentIdFromFSharpRange(range, projectId) | None -> self.TryGetDocumentIdFromFSharpRange range |> Option.map self.GetDocument + +type Document with + + /// Runs a lookup against this document's solution and, when it finds nothing, against the + /// workspace's current solution: the document may come from a snapshot taken before every + /// project of the solution finished loading. + member document.TryFindInSolutions(find: Solution -> 'T voption) = + match find document.Project.Solution with + | ValueSome found -> ValueSome found + | ValueNone -> find document.Project.Solution.Workspace.CurrentSolution + + /// Every document with the file path, from whichever project includes it. + member document.GetSolutionDocumentsWithFilePath(filePath: string) = + let filePath = Path.GetFullPathSafe filePath + + document.TryFindInSolutions(fun solution -> + match solution.GetDocumentIdsWithFilePath filePath with + | ids when ids.IsEmpty -> ValueNone + | ids -> ValueSome [ for id in ids -> solution.GetDocument id ]) + |> ValueOption.defaultValue [] + + member document.TryGetSolutionDocumentFromPath(filePath: string) = + document.GetSolutionDocumentsWithFilePath filePath |> Seq.tryHeadV + + /// The document for the range's file, preferring this document's project or one it depends on. + member document.TryGetSolutionDocumentFromFSharpRange(range: range) = + document.TryFindInSolutions(fun solution -> + solution.TryGetDocumentFromFSharpRange(range, document.Project.Id) + |> ValueOption.ofOption) diff --git a/vsintegration/src/FSharp.Editor/Common/Constants.fs b/vsintegration/src/FSharp.Editor/Common/Constants.fs index ead451467cf..d0f493af6df 100644 --- a/vsintegration/src/FSharp.Editor/Common/Constants.fs +++ b/vsintegration/src/FSharp.Editor/Common/Constants.fs @@ -43,6 +43,11 @@ module internal FSharpConstants = /// "F# Language Service" let FSharpLanguageServiceCallbackName = "F# Language Service" + [] + /// Brokered service offering F# declarations to the Copilot chat "#" mention picker. + let copilotSymbolProviderName = + "Microsoft.VisualStudio.FSharp.CopilotSymbolContextProvider" + [] /// "FSharp" let FSharpLanguageLongName = "FSharp" diff --git a/vsintegration/src/FSharp.Editor/Common/Extensions.fs b/vsintegration/src/FSharp.Editor/Common/Extensions.fs index 82d161a7e64..22b92c9a224 100644 --- a/vsintegration/src/FSharp.Editor/Common/Extensions.fs +++ b/vsintegration/src/FSharp.Editor/Common/Extensions.fs @@ -71,14 +71,14 @@ type Project with type TextViewEventsHandler ( - onChangeCaretHandler: (IVsTextView * int * int -> unit) option, - onKillFocus: (IVsTextView -> unit) option, - onSetFocus: (IVsTextView -> unit) option + onChangeCaretHandler: (IVsTextView * int * int -> unit) voption, + onKillFocus: (IVsTextView -> unit) voption, + onSetFocus: (IVsTextView -> unit) voption ) = interface IVsTextViewEvents with member this.OnChangeCaretLine(view: IVsTextView, newline: int, oldline: int) = onChangeCaretHandler - |> Option.iter (fun handler -> handler (view, newline, oldline)) + |> ValueOption.iter (fun handler -> handler (view, newline, oldline)) member this.OnChangeScrollInfo (_view: IVsTextView, _iBar: int, _iMinUnit: int, _iMaxUnits: int, _iVisibleUnits: int, _iFirstVisibleUnit: int) @@ -86,14 +86,14 @@ type TextViewEventsHandler () member this.OnKillFocus(view: IVsTextView) = - onKillFocus |> Option.iter (fun handler -> handler (view)) + onKillFocus |> ValueOption.iter (fun handler -> handler (view)) member this.OnSetBuffer(_view: IVsTextView, _buffer: IVsTextLines) = () member this.OnSetFocus(view: IVsTextView) = - onSetFocus |> Option.iter (fun handler -> handler (view)) + onSetFocus |> ValueOption.iter (fun handler -> handler (view)) -type ConnectionPointSubscription = System.IDisposable option +type ConnectionPointSubscription = System.IDisposable voption // Usage example: // If a handler is None, to not handle that event @@ -108,16 +108,23 @@ let subscribeToTextViewEvents (textView: IVsTextView, onChangeCaretHandler, onKi let mutable cookie = 0u match cpContainer.FindConnectionPoint(ref riid) with - | null -> None + | null -> ValueNone | cp -> - Some( - cp.Advise(handler, &cookie) + cp.Advise(handler, &cookie) + + // `Unadvise` is not idempotent: a second call with the same cookie throws. + // Guard it so that a duplicate `Dispose()` is a no-op rather than an exception + // surfacing on whatever thread happened to dispose the subscription twice. + let unadvised = ref 0 + ValueSome( { new IDisposable with - member _.Dispose() = cp.Unadvise(cookie) + member _.Dispose() = + if Interlocked.Exchange(&unadvised.contents, 1) = 0 then + cp.Unadvise(cookie) } ) - | _ -> None + | _ -> ValueNone type Document with @@ -129,7 +136,7 @@ type Document with | null -> None | languageServices -> languageServices.GetService<'T>() |> Some - member this.TryGetIVsTextView() : IVsTextView option = + member this.TryGetIVsTextView() : IVsTextView voption = match ServiceProvider.GlobalProvider.GetService(typeof) with | :? IVsTextManager as textManager -> // Grab IVsRunningDocumentTable @@ -140,20 +147,20 @@ type Document with match Marshal.GetObjectForIUnknown docData with | :? IVsTextBuffer as ivsTextBuffer -> match textManager.GetActiveView(0, ivsTextBuffer) with - | hr, vsTextView when ErrorHandler.Succeeded(hr) -> Some vsTextView - | _ -> None - | _ -> None - | _ -> None - | _ -> None - | _ -> None - - member this.TryGetTextViewAndCaretPos() : (IVsTextView * Position) option = + | hr, vsTextView when ErrorHandler.Succeeded(hr) -> ValueSome vsTextView + | _ -> ValueNone + | _ -> ValueNone + | _ -> ValueNone + | _ -> ValueNone + | _ -> ValueNone + + member this.TryGetTextViewAndCaretPos() : (IVsTextView * Position) voption = match this.TryGetIVsTextView() with - | Some textView -> + | ValueSome textView -> match textView.GetCaretPos() with - | hr, line, column when ErrorHandler.Succeeded(hr) -> Some(textView, Position.fromZ line column) - | _ -> None - | None -> None + | hr, line, column when ErrorHandler.Succeeded(hr) -> ValueSome(textView, Position.fromZ line column) + | _ -> ValueNone + | ValueNone -> ValueNone member this.IsFSharpScript = isScriptFile this.FilePath @@ -296,6 +303,14 @@ type SourceText with member this.ToFSharpSourceText() = SourceText.weakTable.GetValue(this, Runtime.CompilerServices.ConditionalWeakTable<_, _>.CreateValueCallback(SourceText.create)) + /// The lines of the text, as slices of a single string rather than one string per line. + member this.GetLinesAsMemory() = + let text = this.ToString() + + Array.init this.Lines.Count (fun i -> + let line = this.Lines[i] + text.AsMemory(line.Start, line.End - line.Start)) + type NavigationItem with member x.RoslynGlyph: FSharpRoslynGlyph = @@ -409,6 +424,33 @@ module Option = else None +[] +module ValueOption = + + let guard (x: bool) : ValueOption = if x then ValueSome() else ValueNone + + let attempt (f: unit -> 'T) = + try + Some <| f () + with _ -> + None + + /// Returns 'Some list' if all elements in the list are Some, otherwise None + let ofOptionList (xs: 'a option list) : 'a list voption = + + if xs |> List.forall Option.isSome then + xs |> List.map Option.get |> ValueSome + else + ValueNone + + /// Returns 'Some list' if all elements in the list are Some, otherwise None + let ofValueOptionList (xs: 'a ValueOption list) : 'a list ValueOption = + + if xs |> List.forall ValueOption.isSome then + xs |> List.map ValueOption.get |> ValueSome + else + ValueNone + [] module IEnumerator = let chooseV f (e: IEnumerator<'T>) = @@ -498,7 +540,7 @@ module Seq = res - let chooseV (chooser: 'a -> 'b voption) source = + let chooseV< 'a, 'b > (chooser: 'a -> 'b voption) source = revamp (IEnumerator.chooseV chooser) source [] @@ -527,6 +569,18 @@ module Array = loop 0 + let inline tryPickV ([] chooser: 'T -> 'U voption) (array: 'T[]) = + + let rec loop i = + if i >= array.Length then + ValueNone + else + match chooser array[i] with + | ValueNone -> loop (i + 1) + | res -> res + + loop 0 + let inline chooseV ([] chooser: 'T -> 'U voption) (array: 'T[]) = let mutable i = 0 diff --git a/vsintegration/src/FSharp.Editor/Common/RoslynHelpers.fs b/vsintegration/src/FSharp.Editor/Common/RoslynHelpers.fs index 2679740fbb3..d7439b18b57 100644 --- a/vsintegration/src/FSharp.Editor/Common/RoslynHelpers.fs +++ b/vsintegration/src/FSharp.Editor/Common/RoslynHelpers.fs @@ -135,7 +135,9 @@ module internal RoslynHelpers = return! computation } - let tcs = new TaskCompletionSource<_>(TaskCreationOptions.None) + let tcs = + new TaskCompletionSource<_>(TaskCreationOptions.RunContinuationsAsynchronously) + let barrier = VolatileBarrier() let reg = diff --git a/vsintegration/src/FSharp.Editor/Copilot/CopilotContextProvider.fs b/vsintegration/src/FSharp.Editor/Copilot/CopilotContextProvider.fs new file mode 100644 index 00000000000..fdddf05793e --- /dev/null +++ b/vsintegration/src/FSharp.Editor/Copilot/CopilotContextProvider.fs @@ -0,0 +1,440 @@ +// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information. + +namespace Microsoft.VisualStudio.FSharp.Editor + +open System +open System.Collections.Generic +open System.ComponentModel.Composition +open System.IO +open System.Diagnostics +open System.Threading +open System.Threading.Tasks + +open Microsoft.CodeAnalysis +open Microsoft.CodeAnalysis.ExternalAccess.FSharp.Navigation +open Microsoft.CodeAnalysis.Text +open Microsoft.ServiceHub.Framework +open Microsoft.VisualStudio.Copilot +open Microsoft.VisualStudio.LanguageServices +open Microsoft.VisualStudio.Shell +open Microsoft.VisualStudio.Shell.ServiceBroker + +open FSharp.Compiler.EditorServices +open CancellableTasks + +/// Solution-wide lookup of F# declarations behind the Copilot chat "#" mention picker. +/// Kept apart from the brokered service so it can be exercised without a Visual Studio workspace. +module internal CopilotSymbolQuery = + + [] + let private MaxMentions = 20 + + /// Overloads and partial definitions share one fully qualified name; a handful of them is plenty of context. + [] + let private MaxDeclarations = 4 + + [] + let private UserOpName = "CopilotSymbolContext" + + /// How long a query keeps parsing files nobody has opened yet. Copilot cancels on its own schedule + /// and takes no partial results, so an answer from what is already parsed beats a complete answer. + [] + let private ColdSearchBudgetMs = 1500 + + let private parallelism = max 1 (Environment.ProcessorCount - 1) + + let private fsharpDocuments (solution: Solution) = + solution.Projects + |> Seq.where (fun project -> project.Language = FSharpConstants.FSharpLanguageName) + |> Seq.collect _.Documents + + /// The documents in the order a query visits them: the ones the user has open, the ones already + /// parsed into the cache, and the ones that would have to be parsed to answer. + let private tiers (cache: FSharpNavigableItemsCache) (openDocumentIds: DocumentId seq) (solution: Solution) = + let openIds = HashSet openDocumentIds + let opened = ResizeArray() + let cached = ResizeArray() + let cold = ResizeArray() + + for document in fsharpDocuments solution do + if openIds.Contains document.Id then + opened.Add document + else + match cache.TryGetCachedNavigableItems document.Id with + | ValueSome items -> cached.Add(struct (document, items)) + | ValueNone -> cold.Add document + + struct (opened, cached, cold) + + let describe (item: NavigableItem) (document: Document) = + let container = + match item.Container.FullName with + | "" -> Path.GetFileName document.FilePath + | name -> name + + if document.IsFSharpSignatureFile then + $"signature, {container} - {document.Project.Name}" + else + $"{container} - {document.Project.Name}" + + /// Declarations whose fully qualified name matches each search text, best match first, one entry + /// per name. Every document is visited once for all of the texts. + let search (cache: FSharpNavigableItemsCache) (openDocumentIds: DocumentId seq) (solution: Solution) (searchTexts: string[]) = + cancellableTask { + let! ct = CancellableTask.getCancellationToken () + let matchers = searchTexts |> Array.map cache.CreateMatcherFor + let hits = Array.init searchTexts.Length (fun _ -> ResizeArray()) + let found = Array.init searchTexts.Length (fun _ -> HashSet StringComparer.Ordinal) + + let collect (document: Document) (items: NavigableItem array) = + lock hits (fun () -> + for index in 0 .. matchers.Length - 1 do + let tryMatch = matchers[index] + + for item in items do + match tryMatch item with + | ValueSome patternMatch -> + hits[index].Add(struct (patternMatch.Kind, item, document)) + + found[index].Add(CopilotSymbolMapping.fullyQualifiedName item) |> ignore + | ValueNone -> ()) + + let enough () = + lock hits (fun () -> found |> Array.forall (fun names -> names.Count >= MaxMentions)) + + let parseAndCollect (document: Document) = + cancellableTask { + let! items = cache.GetNavigableItems document + collect document items + } + + let struct (opened, cached, cold) = tiers cache openDocumentIds solution + + do! opened |> CancellableTask.forEachThrottled parallelism parseAndCollect + + if not (enough ()) then + for struct (document, items) in cached do + ct.ThrowIfCancellationRequested() + collect document items + + if not (enough ()) then + let budget = Stopwatch.StartNew() + + // The budget stops handing out documents rather than cancelling a parse under way: + // the first query of a session has to survive its first parse to answer at all. + let scan (document: Document) = + cancellableTask { + if budget.ElapsedMilliseconds < ColdSearchBudgetMs && not (enough ()) then + do! parseAndCollect document + } + + do! cold |> CancellableTask.forEachThrottled parallelism scan + + return + hits + |> Array.map ( + Seq.sortBy (fun (struct (kind, item: NavigableItem, document: Document)) -> + document.IsFSharpSignatureFile, kind, item.Name.Length) + >> Seq.distinctBy (fun (struct (_, item, _)) -> CopilotSymbolMapping.fullyQualifiedName item) + >> Seq.truncate MaxMentions + >> Seq.map (fun (struct (_, item, document)) -> struct (item, document)) + >> Seq.toArray + ) + } + + /// Declarations carrying exactly this fully qualified name. Signature files answer only when no + /// implementation declares the name, so the search stops once an implementation has answered. + let declarationsOf + (cache: FSharpNavigableItemsCache) + (openDocumentIds: DocumentId seq) + (solution: Solution) + (fullyQualifiedName: string) + = + cancellableTask { + let! ct = CancellableTask.getCancellationToken () + let hits = ResizeArray() + + let collect (document: Document) (items: NavigableItem array) = + lock hits (fun () -> + for item in items do + if CopilotSymbolMapping.hasFullyQualifiedName fullyQualifiedName item then + hits.Add(struct (item, document))) + + let declaredInImplementation () = + lock hits (fun () -> + hits + |> Seq.exists (fun (struct (_, document: Document)) -> not document.IsFSharpSignatureFile)) + + let parseAndCollect (document: Document) = + cancellableTask { + let! items = cache.GetNavigableItems document + collect document items + } + + let struct (opened, cached, cold) = tiers cache openDocumentIds solution + + do! opened |> CancellableTask.forEachThrottled parallelism parseAndCollect + + if not (declaredInImplementation ()) then + for struct (document, items) in cached do + ct.ThrowIfCancellationRequested() + collect document items + + if not (declaredInImplementation ()) then + let scan (document: Document) = + cancellableTask { + if not (declaredInImplementation ()) then + do! parseAndCollect document + } + + do! cold |> CancellableTask.forEachThrottled parallelism scan + + let implementations = + hits + |> Seq.filter (fun (struct (_, document: Document)) -> not document.IsFSharpSignatureFile) + + let preferred = + if Seq.isEmpty implementations then + hits :> _ seq + else + implementations + + return preferred |> Seq.truncate MaxDeclarations |> Seq.toArray + } + + /// The source of the whole declaration `item` names, together with the span it occupies. + let snippetOf (item: NavigableItem) (document: Document) = + cancellableTask { + let! ct = CancellableTask.getCancellationToken () + let! sourceText = document.GetTextAsync ct + let! parseResults = document.GetFSharpParseResultsAsync UserOpName + + let sourceLines = sourceText.GetLinesAsMemory() + + let scopes = Structure.getOutliningRanges sourceLines parseResults.ParseTree + + let struct (firstLine, lastLine) = + CopilotSymbolSnippets.definitionLines sourceLines scopes item + + let firstLine = max 1 firstLine + let lastLine = min sourceText.Lines.Count lastLine + + let span = + TextSpan.FromBounds(sourceText.Lines[firstLine - 1].Start, sourceText.Lines[lastLine - 1].End) + + return struct (sourceText.GetSubText(span).ToString(), span) + } + + let symbolContext + (cache: FSharpNavigableItemsCache) + (openDocumentIds: DocumentId seq) + (solution: Solution) + (fullyQualifiedName: string) + = + cancellableTask { + let! declarations = declarationsOf cache openDocumentIds solution fullyQualifiedName + + match Array.tryHeadV declarations with + | ValueNone -> return ValueNone + | ValueSome(struct (first, _)) -> + let snippets = ResizeArray() + let locations = ResizeArray() + + for struct (item, document) in declarations do + let! struct (text, span) = snippetOf item document + snippets.Add text + locations.Add(SnippetLocation(document.FilePath, CopilotSpan(span.Start, span.Length))) + + return + ValueSome( + CopilotSymbolContext( + fullyQualifiedName, + first.Name, + String.Join(Environment.NewLine + Environment.NewLine, snippets), + CopilotSymbolMapping.symbolContextType first.Kind, + locations.ToArray() + ) + ) + } + +/// Offers F# declarations to Copilot chat, which merges them into the picker shown for "#". +/// Copilot's own symbol provider reads the Roslyn compilation, which F# projects do not have. +[; typeof |], + Audience = (ServiceAudience.PublicSdk ||| ServiceAudience.Local))>] +type internal FSharpCopilotContextProvider + [] + (cache: FSharpNavigableItemsCache, [] workspace: VisualStudioWorkspace | null) = + + static let moniker = + ServiceMoniker(FSharpConstants.copilotSymbolProviderName, Version CopilotDescriptors.CurrentContextProviderVersion) + + static let descriptor = + CopilotContextDescriptor( + CopilotSymbolMapping.SymbolMember, + "An F# type, module, member or value declared in the current solution.", + CopilotDefaultTypes.SymbolContextName, + [| + CopilotInputDescriptor( + CopilotSymbolMapping.FullyQualifiedNameInput, + "Fully qualified name of the F# declaration.", + CopilotDefaultTypes.StringName, + IsRequired = true + ) + |] + ) + + static let members = [| descriptor |] :> IReadOnlyList + + static let memberNames = [| CopilotSymbolMapping.SymbolMember |] :> IReadOnlyList + + static let noMentions = + Array.empty :> IReadOnlyCollection + + let mentionFor (item: NavigableItem) (document: Document) = + let inputs = Dictionary(StringComparer.Ordinal) + + inputs[CopilotSymbolMapping.FullyQualifiedNameInput] <- + CopilotValue(CopilotDefaultTypes.StringName, CopilotSymbolMapping.fullyQualifiedName item) + + let description = CopilotSymbolQuery.describe item document + + CopilotQueriedContextMention( + moniker, + descriptor, + inputs, + item.Name, + Description = description, + Tooltip = description, + Icon = Nullable(CopilotSymbolMapping.icon item.Kind), + IsNavigable = true + ) + :> CopilotQueriedMention + + /// The user is still typing, so the trailing input is the search text. It is preceded by the member + /// name once the mention has been committed, as in "#fsharpSymbol:Namespace.Type". + let searchTextOf (query: CopilotMentionQuery) = + match query.Type, query.Inputs with + | CopilotMentionType.Context, null -> ValueNone + | CopilotMentionType.Context, inputs when inputs.Count > 0 -> + match inputs[inputs.Count - 1] with + | text when String.IsNullOrWhiteSpace text -> ValueNone + | text when String.Equals(text, CopilotSymbolMapping.SymbolMember, StringComparison.Ordinal) -> ValueNone + | text -> ValueSome text + | _ -> ValueNone + + /// One pass over the solution for the whole batch: Copilot's picker asks for several texts at once + /// and each of them would otherwise walk the same documents. + let mentionsFor (searchTexts: string voption[]) = + cancellableTask { + let distinct = searchTexts |> Seq.chooseV id |> Seq.distinct |> Seq.toArray + + match workspace with + | null -> return searchTexts |> Array.map (fun _ -> noMentions) + | _ when Array.isEmpty distinct -> return searchTexts |> Array.map (fun _ -> noMentions) + | workspace -> + let! hits = CopilotSymbolQuery.search cache (workspace.GetOpenDocumentIds()) workspace.CurrentSolution distinct + + let byText = Dictionary(StringComparer.Ordinal) + + for index in 0 .. distinct.Length - 1 do + byText[distinct[index]] <- + hits[index] + |> Array.map (fun (struct (item, document)) -> mentionFor item document) + :> IReadOnlyCollection + + return + searchTexts + |> Array.map (function + | ValueSome text -> byText[text] + | ValueNone -> noMentions) + } + + let fullyQualifiedNameOf (inputs: IReadOnlyDictionary | null) = + match inputs with + | null -> ValueNone + | inputs -> + match inputs.TryGetValue CopilotSymbolMapping.FullyQualifiedNameInput with + | true, value -> + match value.TryGetValue() with + | true, name when not (String.IsNullOrWhiteSpace name) -> ValueSome name + | _ -> ValueNone + | _ -> ValueNone + + interface IExportedBrokeredService with + member _.Descriptor = CopilotDescriptors.CreateContextProviderDescriptor moniker + + member _.InitializeAsync _cancellationToken = Task.CompletedTask + + interface ICopilotContextReducer with + member _.ReduceAsync(context, _reduction, _counter, _cancellationToken) = Task.FromResult context + + interface ICopilotContextProvider with + member _.GetMembersAsync _cancellationToken = + ValueTask> members + + member _.GetMembersAsync(_requestId, _cancellationToken) = Task.FromResult memberNames + + member _.StoreAsync(_requestId, _cancellationToken) = ValueTask() + + member _.ReleaseAsync(_requestId, _cancellationToken) = ValueTask() + + member _.GetContextAsync(requestId, memberName, inputs, cancellationToken) : Task = + match workspace, fullyQualifiedNameOf inputs with + | null, _ + | _, ValueNone -> Task.FromResult null + | workspace, ValueSome fullyQualifiedName when + String.Equals(memberName, CopilotSymbolMapping.SymbolMember, StringComparison.Ordinal) + -> + cancellableTask { + let! symbol = + CopilotSymbolQuery.symbolContext cache (workspace.GetOpenDocumentIds()) workspace.CurrentSolution fullyQualifiedName + + match symbol with + | ValueNone -> return null + | ValueSome symbol -> return CopilotContext(moniker, descriptor, requestId, symbol, CanReduce = false) + } + |> CancellableTask.start cancellationToken + | _ -> Task.FromResult null + + interface ICopilotMentionQueryable with + member _.QueryMentionAsync(query, cancellationToken) : Task> = + mentionsFor [| searchTextOf query |] + |> CancellableTask.map Array.head + |> CancellableTask.start cancellationToken + + member _.NavigateToMentionableAsync(mention, cancellationToken) : Task = + match workspace, fullyQualifiedNameOf mention.Inputs with + | null, _ + | _, ValueNone -> Task.FromResult false + | workspace, ValueSome fullyQualifiedName -> + cancellableTask { + let! ct = CancellableTask.getCancellationToken () + let solution = workspace.CurrentSolution + + let! declarations = CopilotSymbolQuery.declarationsOf cache (workspace.GetOpenDocumentIds()) solution fullyQualifiedName + + match Array.tryHeadV declarations with + | ValueNone -> return false + | ValueSome(struct (item, document)) -> + let! sourceText = document.GetTextAsync ct + + match RoslynHelpers.TryFSharpRangeToTextSpan(sourceText, item.Range) with + | ValueNone -> return false + | ValueSome span -> + do! ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync ct + + let navigation = + solution.Workspace.Services.GetService() + + return navigation.TryNavigateToSpan(solution.Workspace, document.Id, span, ct) + } + |> CancellableTask.start cancellationToken + + // Copilot's own picker providers answer through the batch interface, one result collection per query. + interface ICopilotMentionBatchQueryable with + member _.QueryMentionBatchAsync(queries, cancellationToken) : Task>> = + mentionsFor (queries |> Seq.map searchTextOf |> Seq.toArray) + |> CancellableTask.map (fun mentions -> mentions :> IReadOnlyList>) + |> CancellableTask.start cancellationToken diff --git a/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolMapping.fs b/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolMapping.fs new file mode 100644 index 00000000000..21e296c0476 --- /dev/null +++ b/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolMapping.fs @@ -0,0 +1,74 @@ +// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information. + +/// Translates F# navigable items into the shapes the Copilot chat "#" mention picker understands. +module internal Microsoft.VisualStudio.FSharp.Editor.CopilotSymbolMapping + +open System + +open Microsoft.VisualStudio.Copilot +open Microsoft.VisualStudio.Imaging + +open FSharp.Compiler.EditorServices + +/// Name of the context member. It becomes the mention prefix the user sees and re-types, +/// as in "#fsharpSymbol:Namespace.Type.Member". +[] +let SymbolMember = "fsharpSymbol" + +[] +let FullyQualifiedNameInput = "fullyQualifiedName" + +/// The parse tree cannot tell an interface, struct or record apart from a plain class, so every +/// type-like declaration is reported as a class. +let symbolContextType kind = + match kind with + | NavigableItemKind.Module + | NavigableItemKind.ModuleAbbreviation + | NavigableItemKind.Exception + | NavigableItemKind.Type -> CopilotSymbolContextType.Class + | NavigableItemKind.ModuleValue -> CopilotSymbolContextType.Function + | NavigableItemKind.Field + | NavigableItemKind.Property -> CopilotSymbolContextType.Field + | NavigableItemKind.Constructor + | NavigableItemKind.Member -> CopilotSymbolContextType.Method + | NavigableItemKind.EnumCase -> CopilotSymbolContextType.Constant + | NavigableItemKind.UnionCase -> CopilotSymbolContextType.Union + +let private imageId kind = + match kind with + | NavigableItemKind.Module + | NavigableItemKind.ModuleAbbreviation -> KnownImageIds.ModulePublic + | NavigableItemKind.Exception -> KnownImageIds.ExceptionPublic + | NavigableItemKind.Type -> KnownImageIds.ClassPublic + | NavigableItemKind.ModuleValue + | NavigableItemKind.Constructor + | NavigableItemKind.Member -> KnownImageIds.MethodPublic + | NavigableItemKind.Field -> KnownImageIds.FieldPublic + | NavigableItemKind.Property -> KnownImageIds.PropertyPublic + | NavigableItemKind.EnumCase + | NavigableItemKind.UnionCase -> KnownImageIds.EnumerationItemPublic + +let icon kind = + CopilotImageMoniker(Guid = KnownImageIds.ImageCatalogGuid, Id = imageId kind) + +/// Dotted path that both drives the picker's pattern matching and identifies a picked mention +/// when it is resolved back to source. +let fullyQualifiedName (item: NavigableItem) = + match item.Container.FullName with + | "" -> item.Name + | container -> $"{container}.{item.Name}" + +/// Answers what comparing against `fullyQualifiedName` would, without building the dotted path - +/// a solution-wide scan asks this of every declaration it walks past. +let hasFullyQualifiedName (candidate: string) (item: NavigableItem) = + let candidate = candidate.AsSpan() + let container = item.Container.FullName + let name = item.Name.AsSpan() + + if container.Length = 0 then + candidate.Equals(name, StringComparison.Ordinal) + else + candidate.Length = container.Length + 1 + name.Length + && candidate[container.Length] = '.' + && candidate.Slice(0, container.Length).Equals(container.AsSpan(), StringComparison.Ordinal) + && candidate.Slice(container.Length + 1).Equals(name, StringComparison.Ordinal) diff --git a/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolSnippets.fs b/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolSnippets.fs new file mode 100644 index 00000000000..fe53cac4f7d --- /dev/null +++ b/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolSnippets.fs @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information. + +/// Widens the identifier range of a navigable item to the declaration a reader would recognise. +module internal Microsoft.VisualStudio.FSharp.Editor.CopilotSymbolSnippets + +open System + +open FSharp.Compiler.EditorServices + +/// A module scope can span a whole file, which is more than a chat prompt can usefully carry. +[] +let MaxSnippetLines = 200 + +/// Inclusive, 1-based line bounds of the declaration `item` names, including its doc comment. +let definitionLines (sourceLines: ReadOnlyMemory array) (scopes: Structure.ScopeRange seq) (item: NavigableItem) = + let declarationLine = item.Range.StartLine + + // A construct's outlining range reaches back over the doc comment in front of it, so it is the + // collapse range - the body proper - that tells which construct is declared on this line. + let declaredHere (scope: Structure.ScopeRange) = + scope.CollapseRange.StartLine = declarationLine + && scope.Range.EndLine >= item.Range.EndLine + && scope.Scope <> Structure.Scope.Comment + && scope.Scope <> Structure.Scope.XmlDocComment + + let mutable widest = ValueNone + + for scope in scopes do + if declaredHere scope then + match widest with + | ValueSome(previous: Structure.ScopeRange) when previous.Range.EndLine >= scope.Range.EndLine -> () + | _ -> widest <- ValueSome scope + + // A one-line member declares no scope of its own; it stands for itself rather than for the type around it. + let firstLine, lastLine = + match widest with + | ValueSome scope -> scope.Range.StartLine, scope.Range.EndLine + | ValueNone -> declarationLine, item.Range.EndLine + + // Outlining reports a doc comment only once it spans several lines, so a one-line "///" in front of + // a declaration is invisible to the scopes above. + let isDocComment line = + sourceLines[line - 1].Span.TrimStart().StartsWith("///".AsSpan(), StringComparison.Ordinal) + + let rec docCommentStart line = + if line > 1 && isDocComment (line - 1) then + docCommentStart (line - 1) + else + line + + let firstLine = docCommentStart firstLine + + struct (firstLine, min lastLine (firstLine + MaxSnippetLines - 1)) diff --git a/vsintegration/src/FSharp.Editor/Diagnostics/DocumentDiagnosticAnalyzer.fs b/vsintegration/src/FSharp.Editor/Diagnostics/DocumentDiagnosticAnalyzer.fs index a1a2bbe9f90..3e487b57a8c 100644 --- a/vsintegration/src/FSharp.Editor/Diagnostics/DocumentDiagnosticAnalyzer.fs +++ b/vsintegration/src/FSharp.Editor/Diagnostics/DocumentDiagnosticAnalyzer.fs @@ -2,9 +2,11 @@ namespace Microsoft.VisualStudio.FSharp.Editor +open System open System.Composition open System.Collections.Immutable open System.Collections.Generic +open System.Runtime.CompilerServices open System.Threading open System.Threading.Tasks @@ -21,12 +23,28 @@ type internal DiagnosticsType = | Syntax | Semantic -[)>] +[] +type private CachedDiagnostics = + { + /// Diagnostic locations embed it, so a rename that keeps the DocumentId must still invalidate. + FilePath: string + TextVersion: VersionStamp + ProjectVersion: VersionStamp + IsRemoveParensEnabled: bool + Diagnostics: ImmutableArray + } + +[); Shared>] type internal FSharpDocumentDiagnosticAnalyzer [] () = let shouldProduceDiagnostics (document: Document) = document.Project.Solution.GetFSharpExtensionConfig().ShouldProduceDiagnostics() + // DocumentId keys survive solution snapshots, and holding them weakly means a document dropped from the + // solution takes its cached diagnostics with it, so no eviction pass is needed on the request path. + static let syntaxCache = ConditionalWeakTable() + static let semanticCache = ConditionalWeakTable() + static let diagnosticEqualityComparer = { new IEqualityComparer with @@ -72,61 +90,111 @@ type internal FSharpDocumentDiagnosticAnalyzer [] () = let! ct = CancellableTask.getCancellationToken () - let! sourceText = document.GetTextAsync(ct) - let filePath = document.FilePath + let! projectVersion = + match diagnosticType with + // Syntax diagnostics depend on the parsing options (defines, langversion), which the project version covers. + | DiagnosticsType.Syntax -> CancellableTask.singleton document.Project.Version + | DiagnosticsType.Semantic -> (fun ct -> document.Project.GetDependentVersionAsync(ct)) - let errors = HashSet(diagnosticEqualityComparer) + let! textVersion = document.GetTextVersionAsync(ct) - let! parseResults = document.GetFSharpParseResultsAsync("GetDiagnostics") + let filePath = document.FilePath - match diagnosticType with - | DiagnosticsType.Syntax -> - for diagnostic in parseResults.Diagnostics do - errors.Add(diagnostic) |> ignore + let isRemoveParensEnabled = + match diagnosticType with + | DiagnosticsType.Syntax -> document.Project.IsFsharpRemoveParensEnabled + | DiagnosticsType.Semantic -> false - | DiagnosticsType.Semantic -> - let! _, checkResults = document.GetFSharpParseAndCheckResultsAsync("GetDiagnostics") + let cache = + match diagnosticType with + | DiagnosticsType.Syntax -> syntaxCache + | DiagnosticsType.Semantic -> semanticCache - for diagnostic in checkResults.Diagnostics do - errors.Add(diagnostic) |> ignore + let cached = + match cache.TryGetValue document.Id with + | true, cachedEntry when + String.Equals(cachedEntry.FilePath, filePath, StringComparison.Ordinal) + && cachedEntry.TextVersion = textVersion + && cachedEntry.ProjectVersion = projectVersion + && cachedEntry.IsRemoveParensEnabled = isRemoveParensEnabled + -> + ValueSome cachedEntry.Diagnostics + | _ -> ValueNone - errors.ExceptWith(parseResults.Diagnostics) + match cached with + | ValueSome cachedDiagnostics -> return cachedDiagnostics + | ValueNone -> - let! unnecessaryParentheses = - match diagnosticType with - | DiagnosticsType.Syntax when document.Project.IsFsharpRemoveParensEnabled -> - UnnecessaryParenthesesDiagnosticAnalyzer.GetDiagnostics document - | _ -> CancellableTask.singleton ImmutableArray.Empty + let! sourceText = document.GetTextAsync(ct) - if errors.Count = 0 && unnecessaryParentheses.IsEmpty then - return ImmutableArray.Empty - else - let iab = ImmutableArray.CreateBuilder(errors.Count + unnecessaryParentheses.Length) + let errors = HashSet(diagnosticEqualityComparer) - for diagnostic in errors do - if diagnostic.StartLine <> 0 && diagnostic.EndLine <> 0 then - let linePositionSpan = - LinePositionSpan( - LinePosition(diagnostic.StartLine - 1, diagnostic.StartColumn), - LinePosition(diagnostic.EndLine - 1, diagnostic.EndColumn) - ) + match diagnosticType with + | DiagnosticsType.Syntax -> + let! parseResults = document.GetFSharpParseResultsAsync("GetDiagnostics") - let textSpan = sourceText.Lines.GetTextSpan(linePositionSpan) + for diagnostic in parseResults.Diagnostics do + errors.Add(diagnostic) |> ignore - // F# compiler report errors at end of file if parsing fails. It should be corrected to match Roslyn boundaries - let correctedTextSpan = - if textSpan.End <= sourceText.Length then - textSpan - else - let start = min textSpan.Start (sourceText.Length - 1) |> max 0 + | DiagnosticsType.Semantic -> + let! parseResults, checkResults = document.GetFSharpParseAndCheckResultsAsync("GetDiagnostics") - TextSpan.FromBounds(start, sourceText.Length) + for diagnostic in checkResults.Diagnostics do + errors.Add(diagnostic) |> ignore - let location = Location.Create(filePath, correctedTextSpan, linePositionSpan) - iab.Add(RoslynHelpers.ConvertError(diagnostic, location)) + errors.ExceptWith(parseResults.Diagnostics) - iab.AddRange unnecessaryParentheses - return iab.ToImmutable() + let! unnecessaryParentheses = + match diagnosticType with + | DiagnosticsType.Syntax when isRemoveParensEnabled -> UnnecessaryParenthesesDiagnosticAnalyzer.GetDiagnostics document + | _ -> CancellableTask.singleton ImmutableArray.Empty + + let result = + if errors.Count = 0 && unnecessaryParentheses.IsEmpty then + ImmutableArray.Empty + else + let iab = ImmutableArray.CreateBuilder(errors.Count + unnecessaryParentheses.Length) + + for diagnostic in errors do + if diagnostic.StartLine <> 0 && diagnostic.EndLine <> 0 then + let linePositionSpan = + LinePositionSpan( + LinePosition(diagnostic.StartLine - 1, diagnostic.StartColumn), + LinePosition(diagnostic.EndLine - 1, diagnostic.EndColumn) + ) + + let textSpan = sourceText.Lines.GetTextSpan(linePositionSpan) + + // F# compiler report errors at end of file if parsing fails. It should be corrected to match Roslyn boundaries + let correctedTextSpan = + if textSpan.End <= sourceText.Length then + textSpan + else + let start = min textSpan.Start (sourceText.Length - 1) |> max 0 + + TextSpan.FromBounds(start, sourceText.Length) + + let location = Location.Create(filePath, correctedTextSpan, linePositionSpan) + iab.Add(RoslynHelpers.ConvertError(diagnostic, location)) + + iab.AddRange unnecessaryParentheses + iab.ToImmutable() + + let entry = + { + FilePath = filePath + TextVersion = textVersion + ProjectVersion = projectVersion + IsRemoveParensEnabled = isRemoveParensEnabled + Diagnostics = result + } + + // net472 has no ConditionalWeakTable.AddOrUpdate, and Remove + Add races with a concurrent pass. + lock cache (fun () -> + cache.Remove document.Id |> ignore + cache.Add(document.Id, entry)) + + return result } interface IFSharpDocumentDiagnosticAnalyzer with diff --git a/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj b/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj index 319bdd5a264..63173c58e00 100644 --- a/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj +++ b/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj @@ -23,6 +23,11 @@ true Microsoft.VisualStudio.FSharp.Editor.SR + + + + VSPackage LegacyResolver.txt @@ -55,6 +60,7 @@ + @@ -92,8 +98,12 @@ + + + + @@ -179,6 +189,7 @@ + diff --git a/vsintegration/src/FSharp.Editor/InlineRename/InlineRenameService.fs b/vsintegration/src/FSharp.Editor/InlineRename/InlineRenameService.fs index 8a5debfdc84..0948ca7cfaa 100644 --- a/vsintegration/src/FSharp.Editor/InlineRename/InlineRenameService.fs +++ b/vsintegration/src/FSharp.Editor/InlineRename/InlineRenameService.fs @@ -3,6 +3,7 @@ namespace Microsoft.VisualStudio.FSharp.Editor open System +open System.Collections.Concurrent open System.Collections.Generic open System.Collections.Immutable open System.Composition @@ -88,14 +89,21 @@ type internal InlineRenameInfo inherit FSharpInlineRenameInfo() + // Roslyn asks for the span of a location on the UI thread, one call per location, so the text of + // every document holding one is kept from the search that produced them. + let textOfDocument = ConcurrentDictionary() + let getDocumentText (document: Document) = - match document.TryGetText() with + match textOfDocument.TryGetValue document.Id with | true, text -> CancellableTask.singleton text | _ -> - cancellableTask { - let! cancellationToken = CancellableTask.getCancellationToken () - return! document.GetTextAsync(cancellationToken) - } + match document.TryGetText() with + | true, text -> CancellableTask.singleton text + | _ -> + cancellableTask { + let! cancellationToken = CancellableTask.getCancellationToken () + return! document.GetTextAsync(cancellationToken) + } let symbolUses = SymbolHelpers.getSymbolUsesInSolution (symbolUse, checkFileResults, document) ct @@ -159,6 +167,7 @@ type internal InlineRenameInfo let! cancellationToken = CancellableTask.getCancellationToken () let document = document.Project.Solution.GetDocument(documentId) let! sourceText = document.GetTextAsync(cancellationToken) + textOfDocument[documentId] <- sourceText return [| diff --git a/vsintegration/src/FSharp.Editor/LanguageService/FSharpProjectOptionsManager.fs b/vsintegration/src/FSharp.Editor/LanguageService/FSharpProjectOptionsManager.fs index 08bfbbddaa8..1731c78aa70 100644 --- a/vsintegration/src/FSharp.Editor/LanguageService/FSharpProjectOptionsManager.fs +++ b/vsintegration/src/FSharp.Editor/LanguageService/FSharpProjectOptionsManager.fs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information. +// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information. namespace Microsoft.VisualStudio.FSharp.Editor @@ -8,26 +8,24 @@ open System.Collections.Concurrent open System.Collections.Immutable open System.IO open System.Linq -open Microsoft.CodeAnalysis +open System.Threading +open System.Threading.Tasks open FSharp.Compiler open FSharp.Compiler.CodeAnalysis +open FSharp.Compiler.Text +open Microsoft.CodeAnalysis open Microsoft.VisualStudio.FSharp.Editor -open System.Threading open Microsoft.VisualStudio.FSharp.Interactive.Session -open System.Runtime.CompilerServices -open CancellableTasks -open Microsoft.VisualStudio.FSharp.Editor.Extensions -open System.Windows -open Microsoft.VisualStudio -open FSharp.Compiler.Text open Microsoft.VisualStudio.TextManager.Interop +open CancellableTasks + #nowarn "57" [] module private FSharpProjectOptionsHelpers = - let mapCpsProjectToSite (project: Project, cpsCommandLineOptions: IDictionary) = + let mapCpsProjectToSite (project: Project, cpsCommandLineOptions: IDictionary) = let sourcePaths, referencePaths, options = match cpsCommandLineOptions.TryGetValue(project.Id) with | true, (sourcePaths, options) -> sourcePaths, [||], options @@ -40,13 +38,13 @@ module private FSharpProjectOptionsHelpers = member _.CompilationSourceFiles = sourcePaths member _.CompilationOptions = - Array.concat [ options; referencePaths |> Array.map (fun r -> "-r:" + r) ] + [| yield! options; yield! referencePaths |> Seq.map (fun r -> "-r:" + r) |] member _.CompilationReferences = referencePaths member site.CompilationBinOutputPath = site.CompilationOptions - |> Array.tryPick (fun s -> if s.StartsWith("-o:") then Some s.[3..] else None) + |> Array.tryPickV (fun s -> if s.StartsWith("-o:") then ValueSome s.[3..] else ValueNone) member _.ProjectFileName = project.FilePath member _.AdviseProjectSiteChanges(_, _) = () @@ -66,141 +64,268 @@ module private FSharpProjectOptionsHelpers = let inline hasProjectVersionChanged (oldProject: Project) (newProject: Project) = oldProject.Version <> newProject.Version - let hasDependentVersionChanged (oldProject: Project) (newProject: Project) (ct: CancellationToken) = - let oldProjectMetadataRefs = oldProject.MetadataReferences - let newProjectMetadataRefs = newProject.MetadataReferences + let hasDependentVersionChanged (oldProject: Project) (newProject: Project) = + cancellableTask { + let! ct = CancellableTask.getCancellationToken () + let oldProjectMetadataRefs = oldProject.MetadataReferences + let newProjectMetadataRefs = newProject.MetadataReferences - if oldProjectMetadataRefs.Count <> newProjectMetadataRefs.Count then - true - else + if oldProjectMetadataRefs.Count <> newProjectMetadataRefs.Count then + return true + else - let oldProjectRefs = oldProject.ProjectReferences - let newProjectRefs = newProject.ProjectReferences - - oldProjectRefs.Count() <> newProjectRefs.Count() - || (oldProjectRefs, newProjectRefs) - ||> Seq.exists2 (fun p1 p2 -> - ct.ThrowIfCancellationRequested() - let doesProjectIdDiffer = p1.ProjectId <> p2.ProjectId - let p1 = oldProject.Solution.GetProject(p1.ProjectId) - let p2 = newProject.Solution.GetProject(p2.ProjectId) - - doesProjectIdDiffer - || (if p1.IsFSharp then - p1.Version <> p2.Version - else - let v1 = p1.GetDependentVersionAsync(ct).Result - let v2 = p2.GetDependentVersionAsync(ct).Result - v1 <> v2)) - - let isProjectInvalidated (oldProject: Project) (newProject: Project) ct = - let hasProjectVersionChanged = hasProjectVersionChanged oldProject newProject - - if newProject.AreFSharpInMemoryCrossProjectReferencesEnabled then - hasProjectVersionChanged || hasDependentVersionChanged oldProject newProject ct - else - hasProjectVersionChanged + let oldProjectRefs = oldProject.ProjectReferences + let newProjectRefs = newProject.ProjectReferences + + if oldProjectRefs.Count() <> newProjectRefs.Count() then + return true + else + let mutable result = false + let mutable enum1 = oldProjectRefs.GetEnumerator() + let mutable enum2 = newProjectRefs.GetEnumerator() + + while not result && enum1.MoveNext() && enum2.MoveNext() do + ct.ThrowIfCancellationRequested() + let p1 = enum1.Current + let p2 = enum2.Current + let doesProjectIdDiffer = p1.ProjectId <> p2.ProjectId + let p1 = oldProject.Solution.GetProject(p1.ProjectId) + let p2 = newProject.Solution.GetProject(p2.ProjectId) + + if doesProjectIdDiffer then + result <- true + elif p1.IsFSharp then + result <- p1.Version <> p2.Version + else + let! v1 = p1.GetDependentVersionAsync(ct) + and! v2 = p2.GetDependentVersionAsync(ct) + result <- v1 <> v2 + + return result + } + + let isProjectInvalidated (oldProject: Project) (newProject: Project) = + cancellableTask { + let hasProjectVersionChanged = hasProjectVersionChanged oldProject newProject + + if newProject.AreFSharpInMemoryCrossProjectReferencesEnabled then + if hasProjectVersionChanged then + return true + else + return! hasDependentVersionChanged oldProject newProject + else + return hasProjectVersionChanged + } + +type SingleFileCacheEntry = + { + Project: Project + FileStamp: VersionStamp + ParsingOptions: FSharpParsingOptions + ProjectOptions: FSharpProjectOptions + Subscription: ConnectionPointSubscription + } + +/// +/// The in-memory PE reference of a referenced project, kept while the project's dependent +/// semantic version is unchanged. Roslyn recreates +/// instances freely - on every solution fork, +/// and under memory pressure because it holds the final compilation weakly - and a reference +/// created per instance carries a fresh stamp that invalidates every FCS cache keyed on it. +/// +[] +type private PEReferenceCacheEntry(version: VersionStamp, compilation: Compilation) = + // Pinned until the first emit result, so the reader can always be materialised. + let mutable pinned = compilation + let latest = WeakReference(compilation) + + member _.Version = version + + member _.TryGetCompilation() = + match pinned with + | null -> + match latest.TryGetTarget() with + | true, compilation -> ValueSome compilation + | _ -> ValueNone + | pinned -> ValueSome pinned + + member _.Emitted() = pinned <- null [] type private FSharpProjectOptionsMessage = | TryGetOptionsByDocument of Document * - AsyncReplyChannel<(FSharpParsingOptions * FSharpProjectOptions) voption> * + AsyncReplyChannel * CancellationToken * userOpName: string - | TryGetOptionsByProject of Project * AsyncReplyChannel<(FSharpParsingOptions * FSharpProjectOptions) voption> * CancellationToken + | TryGetOptionsByProject of + Project * + AsyncReplyChannel * + CancellationToken | ClearOptions of ProjectId | ClearSingleFileOptionsCache of DocumentId [] -type private FSharpProjectOptionsReactor(checker: FSharpChecker) = +type private FSharpProjectOptionsReactor(checker: FSharpChecker, fileChangeWatcher: IFSharpFileChangeWatcher) = let cancellationTokenSource = new CancellationTokenSource() // Store command line options - let commandLineOptions = ConcurrentDictionary() + let commandLineOptions = + ConcurrentDictionary() let legacyProjectSites = ConcurrentDictionary() let cache = - ConcurrentDictionary() + ConcurrentDictionary() + + let singleFileCache = ConcurrentDictionary() - let singleFileCache = - ConcurrentDictionary() + let peReferences = + ConcurrentDictionary() - // This is used to not constantly emit the same compilation. - let weakPEReferences = ConditionalWeakTable() let lastSuccessfulCompilations = ConcurrentDictionary() let scriptUpdatedEvent = Event() - let createPEReference (referencedProject: Project) (comp: Compilation) = - let projectId = referencedProject.Id + let disposeSingleFileCacheEntry ({ Subscription = subscription }: SingleFileCacheEntry) = + subscription |> ValueOption.iter (fun subscription -> subscription.Dispose()) + + // The '-r:' set of each project is watched for the reference stamps the snapshot-reuse + // guard reads; invalidating a project's FCS build on a change is Roslyn's job (it swaps the + // MetadataReference and bumps Project.Version, which reaches tryComputeOptions). A script's + // options are dropped instead: its watched set also covers '#load' sources, which the + // workspace does not track. + let referenceWatches = ConcurrentDictionary>() + let scriptReferenceWatches = ConcurrentDictionary>() + + let referencePaths (projectOptions: FSharpProjectOptions) = + seq { + for option in projectOptions.OtherOptions do + if option.StartsWith("-r:", StringComparison.Ordinal) then + option.Substring "-r:".Length + } - match weakPEReferences.TryGetValue comp with - | true, fsRefProj -> fsRefProj + let onWatchedScriptPathChanged (path: string) = + for KeyValue(documentId, paths) in scriptReferenceWatches do + if paths.Contains path then + match singleFileCache.TryRemove documentId with + | true, ({ ProjectOptions = projectOptions } as entry) -> + disposeSingleFileCacheEntry entry + checker.ClearCache([ projectOptions ]) + | _ -> () + + let referenceChangeTracker = + new FSharpReferenceChangeTracker(fileChangeWatcher, onWatchedScriptPathChanged) + + let stopWatching (paths: HashSet) = + for path in paths do + referenceChangeTracker.StopWatchingReference path + + let clearReferenceWatches (projectId: ProjectId) = + match referenceWatches.TryRemove projectId with + | true, paths -> stopWatching paths + | _ -> () + + let clearScriptReferenceWatches (documentId: DocumentId) = + match scriptReferenceWatches.TryRemove documentId with + | true, paths -> stopWatching paths + | _ -> () + + // Diff against what is already watched under the key, so an unchanged set touches no watches. + let updateWatches (watches: ConcurrentDictionary<'Key, HashSet>) (key: 'Key) (paths: HashSet) = + match watches.TryGetValue key with + | true, previous -> + for path in previous do + if not (paths.Contains path) then + referenceChangeTracker.StopWatchingReference path + + for path in paths do + if not (previous.Contains path) then + referenceChangeTracker.StartWatchingReference path | _ -> - let mutable strongComp = comp - let weakComp = WeakReference(comp) - let mutable stamp = DateTime.UtcNow + for path in paths do + referenceChangeTracker.StartWatchingReference path - // Getting a C# reference assembly can fail if there are compilation errors that cannot be resolved. - // To mitigate this, we store the last successful compilation of a C# project and re-use it until we get a new successful compilation. - let getStream = - fun ct -> - let tryStream (comp: Compilation) = - let ms = new MemoryStream() // do not dispose the stream as it will be owned on the reference. + if paths.Count > 0 then + watches[key] <- paths + else + watches.TryRemove key |> ignore - let emitOptions = - Emit.EmitOptions(metadataOnly = true, includePrivateMembers = false, tolerateErrors = true) + let watchReferenceFiles (projectId: ProjectId) (projectOptions: FSharpProjectOptions) = + updateWatches referenceWatches projectId (HashSet(referencePaths projectOptions, StringComparer.OrdinalIgnoreCase)) - try - let result = comp.Emit(ms, options = emitOptions, cancellationToken = ct) + let watchScriptReferences (documentId: DocumentId) (projectOptions: FSharpProjectOptions) = + let paths = + HashSet(referencePaths projectOptions, StringComparer.OrdinalIgnoreCase) - if result.Success then - strongComp <- Unchecked.defaultof<_> // Stop strongly holding the compilation since we have a result. - lastSuccessfulCompilations.[projectId] <- comp - ms.Position <- 0L - ms :> Stream |> Some - else - strongComp <- Unchecked.defaultof<_> // Stop strongly holding the compilation since we have a result. - ms.Dispose() // it failed, dispose of stream - None - with - | :? OperationCanceledException -> - // Since we cancelled, do not null out the strong compilation ref and update the stamp. - stamp <- DateTime.UtcNow - ms.Dispose() - None - | _ -> - strongComp <- Unchecked.defaultof<_> // Stop strongly holding the compilation since we have a result. - ms.Dispose() // it failed, dispose of stream - None - - let resultOpt = - match weakComp.TryGetTarget() with - | true, comp -> tryStream comp - | _ -> None - - match resultOpt with - | Some _ -> resultOpt - | _ -> - match lastSuccessfulCompilations.TryGetValue(projectId) with - | true, comp -> tryStream comp - | _ -> None + paths.UnionWith projectOptions.SourceFiles + updateWatches scriptReferenceWatches documentId paths - let getStamp = fun () -> stamp + let buildPEReference (referencedProject: Project) (entry: PEReferenceCacheEntry) = + let projectId = referencedProject.Id + let mutable stampTime = DateTime.UtcNow - let fsRefProj = - FSharpReferencedProject.PEReference(getStamp, DelayedILModuleReader(referencedProject.OutputFilePath, getStream)) + let getStream = + fun ct -> + let tryStream (comp: Compilation) = + let ms = new MemoryStream() - weakPEReferences.Add(comp, fsRefProj) - fsRefProj + let emitOptions = + Emit.EmitOptions(metadataOnly = true, includePrivateMembers = false, tolerateErrors = true) + + try + let result = comp.Emit(ms, options = emitOptions, cancellationToken = ct) + + if result.Success then + entry.Emitted() + lastSuccessfulCompilations.[projectId] <- comp + ms.Position <- 0L + ms :> Stream |> ValueSome + else + entry.Emitted() + ms.Dispose() + ValueNone + with + | :? OperationCanceledException -> + stampTime <- DateTime.UtcNow + ms.Dispose() + ValueNone + | _ -> + entry.Emitted() + ms.Dispose() + ValueNone + + let resultOpt = + match entry.TryGetCompilation() with + | ValueSome comp -> tryStream comp + | ValueNone -> ValueNone + + match resultOpt with + | ValueSome _ -> resultOpt + | _ -> + match lastSuccessfulCompilations.TryGetValue(projectId) with + | true, comp -> tryStream comp + | _ -> ValueNone + + let getStampTime () = stampTime + FSharpReferencedProject.PEReference(getStampTime, DelayedILModuleReader(referencedProject.OutputFilePath, getStream)) + + let tryGetPEReference (referencedProject: Project) (version: VersionStamp) = + match peReferences.TryGetValue referencedProject.Id with + | true, struct (entry, reference) when entry.Version = version -> ValueSome reference + | _ -> ValueNone + + let createPEReference (referencedProject: Project) (version: VersionStamp) (comp: Compilation) = + let entry = PEReferenceCacheEntry(version, comp) + let reference = buildPEReference referencedProject entry + peReferences[referencedProject.Id] <- struct (entry, reference) + reference let rec tryComputeOptionsBySingleScriptOrFile (document: Document) userOpName = cancellableTask { let! ct = CancellableTask.getCancellationToken () let! fileStamp = document.GetTextVersionAsync(ct) - let textViewAndCaret () : (IVsTextView * Position) option = document.TryGetTextViewAndCaretPos() + let textViewAndCaret () : (IVsTextView * Position) voption = document.TryGetTextViewAndCaretPos() match singleFileCache.TryGetValue(document.Id) with | false, _ -> @@ -210,7 +335,7 @@ type private FSharpProjectOptionsReactor(checker: FSharpChecker) = let caret = textViewAndCaret () match caret with - | None -> + | ValueNone -> checker.GetProjectOptionsFromScript( document.FilePath, sourceText.ToFSharpSourceText(), @@ -219,7 +344,7 @@ type private FSharpProjectOptionsReactor(checker: FSharpChecker) = userOpName = userOpName ) - | Some(_, caret) -> + | ValueSome(_, caret) -> checker.GetProjectOptionsFromScript( document.FilePath, sourceText.ToFSharpSourceText(), @@ -234,14 +359,13 @@ type private FSharpProjectOptionsReactor(checker: FSharpChecker) = let otherOptions = if project.IsFSharpMetadata then - project.ProjectReferences - |> Seq.map (fun x -> "-r:" + project.Solution.GetProject(x.ProjectId).OutputFilePath) - |> Array.ofSeq - |> Array.append ( - project.MetadataReferences.OfType() - |> Seq.map (fun x -> "-r:" + x.FilePath) - |> Array.ofSeq - ) + [| + for projectReference in project.ProjectReferences do + yield "-r:" + project.Solution.GetProject(projectReference.ProjectId).OutputFilePath + + for metadataReference in project.MetadataReferences.OfType() do + yield "-r:" + metadataReference.FilePath + |] else [||] @@ -266,49 +390,118 @@ type private FSharpProjectOptionsReactor(checker: FSharpChecker) = let parsingOptions, _ = checker.GetParsingOptionsFromProjectOptions(projectOptions) + let mutable debounceCts: CancellationTokenSource | null = null + + let disposeDebounceCts () = + match Interlocked.Exchange(&debounceCts, null) with + | null -> () + | cts -> + cts.Cancel() + cts.Dispose() + let updateProjectOptions () = - async { - let! scriptProjectOptions, _ = getProjectOptionsFromScript textViewAndCaret + let cts = new CancellationTokenSource() + let debounceToken = cts.Token + + match Interlocked.Exchange(&debounceCts, cts) with + | null -> () + | previousCts -> + previousCts.Cancel() + previousCts.Dispose() - checker.NotifyFileChanged(document.FilePath, scriptProjectOptions) - |> Async.Start + backgroundTask { + try + do! Task.Delay(500, debounceToken) + + let! scriptProjectOptions, _ = getProjectOptionsFromScript textViewAndCaret + + do! checker.NotifyFileChanged(document.FilePath, scriptProjectOptions) + with + | :? OperationCanceledException + | :? TaskCanceledException -> () } - |> Async.Start + |> ignore let onChangeCaretHandler (_, _newline: int, _oldline: int) = updateProjectOptions () let onKillFocus (_) = updateProjectOptions () let onSetFocus (_) = updateProjectOptions () - let addToCacheAndSubscribe value = - match value with - | projectId, fileStamp, parsingOptions, projectOptions, _ -> - let subscription = - match textViewAndCaret () with - | Some(textView, _) -> - subscribeToTextViewEvents (textView, (Some onChangeCaretHandler), (Some onKillFocus), (Some onSetFocus)) - | None -> None + let addToCacheAndSubscribe + ({ + Project = _ + FileStamp = fileStamp + ParsingOptions = parsingOptions + ProjectOptions = projectOptions + Subscription = _ + }: SingleFileCacheEntry) + = + let textViewSubscription = + textViewAndCaret () + |> ValueOption.bind (fun (textView, _) -> + subscribeToTextViewEvents ( + textView, + (ValueSome onChangeCaretHandler), + (ValueSome onKillFocus), + (ValueSome onSetFocus) + )) + + let subscription = + ValueSome + { new IDisposable with + member _.Dispose() = + textViewSubscription |> ValueOption.iter _.Dispose() + disposeDebounceCts () + } - (projectId, fileStamp, parsingOptions, projectOptions, subscription) + { + Project = document.Project + FileStamp = fileStamp + ParsingOptions = parsingOptions + ProjectOptions = projectOptions + Subscription = subscription + } singleFileCache.AddOrUpdate( - document.Id, // The key to the cache - (fun _ value -> addToCacheAndSubscribe value), // Function to add the cached value if the key does not exist - (fun _ _ value -> value), // Function to update the value if the key exists - (document.Project, fileStamp, parsingOptions, projectOptions, None) // The value to add or update + document.Id, + (fun _ -> + addToCacheAndSubscribe + { + Project = document.Project + FileStamp = fileStamp + ParsingOptions = parsingOptions + ProjectOptions = projectOptions + Subscription = ValueNone + }), + (fun _ existing -> addToCacheAndSubscribe existing) ) |> ignore - return ValueSome(parsingOptions, projectOptions) + watchScriptReferences document.Id projectOptions + + return ValueSome struct (parsingOptions, projectOptions) + + | true, + { + Project = oldProject + FileStamp = oldFileStamp + ParsingOptions = parsingOptions + ProjectOptions = projectOptions + Subscription = _ + } -> + let! isInvalidated = + if fileStamp <> oldFileStamp then + CancellableTask.singleton true + else + isProjectInvalidated document.Project oldProject - | true, (oldProject, oldFileStamp, parsingOptions, projectOptions, _) -> - if fileStamp <> oldFileStamp || isProjectInvalidated document.Project oldProject ct then + if isInvalidated then match singleFileCache.TryRemove(document.Id) with - | true, (_, _, _, _, Some subscription) -> subscription.Dispose() + | true, cacheEntry -> disposeSingleFileCacheEntry cacheEntry | _ -> () return! tryComputeOptionsBySingleScriptOrFile document userOpName else - return ValueSome(parsingOptions, projectOptions) + return ValueSome struct (parsingOptions, projectOptions) } let tryGetProjectSite (project: Project) = @@ -342,14 +535,18 @@ type private FSharpProjectOptionsReactor(checker: FSharpChecker) = if referencedProject.Language = FSharpConstants.FSharpLanguageName then match! tryComputeOptions referencedProject with | ValueNone -> canBail <- true - | ValueSome(_, projectOptions) -> + | ValueSome struct (_, projectOptions) -> referencedProjects.Add( FSharpReferencedProject.FSharpReference(referencedProject.OutputFilePath, projectOptions) ) elif referencedProject.SupportsCompilation then - let! comp = referencedProject.GetCompilationAsync(ct) - let peRef = createPEReference referencedProject comp - referencedProjects.Add(peRef) + let! version = referencedProject.GetDependentSemanticVersionAsync(ct) + + match tryGetPEReference referencedProject version with + | ValueSome reference -> referencedProjects.Add reference + | ValueNone -> + let! comp = referencedProject.GetCompilationAsync(ct) + referencedProjects.Add(createPEReference referencedProject version comp) if canBail then return ValueNone @@ -362,8 +559,10 @@ type private FSharpProjectOptionsReactor(checker: FSharpChecker) = [| // Clear any references from CompilationOptions. // We get the references from Project.ProjectReferences/Project.MetadataReferences. + // A path map belongs to the build output: applied here it rewrites the file name of + // every range imported from a referenced project, and navigation finds no document. for x in projectSite.CompilationOptions do - if not (x.Contains("-r:")) then + if not (x.Contains("-r:") || x.StartsWith("--pathmap:", StringComparison.Ordinal)) then x for x in project.MetadataReferences.OfType() do @@ -408,35 +607,44 @@ type private FSharpProjectOptionsReactor(checker: FSharpChecker) = if not (Seq.isEmpty projectsToClearCache) then projectsToClearCache - |> Seq.iter (fun pair -> cache.TryRemove pair.Key |> ignore) + |> Seq.iter (fun pair -> + cache.TryRemove pair.Key |> ignore + clearReferenceWatches pair.Key) let options = projectsToClearCache |> Seq.map (fun pair -> - let _, _, projectOptions = pair.Value + let struct (_, _, projectOptions) = pair.Value projectOptions) checker.ClearCache(options, userOpName = "tryComputeOptions") - lastSuccessfulCompilations.ToArray() - |> Array.iter (fun pair -> + for pair in lastSuccessfulCompilations.ToArray() do if not (currentSolution.ContainsProject(pair.Key)) then - lastSuccessfulCompilations.TryRemove(pair.Key) |> ignore) + lastSuccessfulCompilations.TryRemove(pair.Key) |> ignore + + for projectId in peReferences.Keys |> Array.ofSeq do + if not (currentSolution.ContainsProject projectId) then + peReferences.TryRemove projectId |> ignore checker.InvalidateConfiguration(projectOptions, userOpName = "tryComputeOptions") let parsingOptions, _ = checker.GetParsingOptionsFromProjectOptions(projectOptions) - cache.[projectId] <- (project, parsingOptions, projectOptions) + cache.[projectId] <- struct (project, parsingOptions, projectOptions) + + watchReferenceFiles projectId projectOptions + + return ValueSome struct (parsingOptions, projectOptions) - return ValueSome(parsingOptions, projectOptions) + | true, struct (oldProject, parsingOptions, projectOptions) -> + let! isInvalidated = isProjectInvalidated oldProject project - | true, (oldProject, parsingOptions, projectOptions) -> - if isProjectInvalidated oldProject project ct then + if isInvalidated then cache.TryRemove(projectId) |> ignore - return! tryComputeOptions project ct + return! tryComputeOptions project else - return ValueSome(parsingOptions, projectOptions) + return ValueSome struct (parsingOptions, projectOptions) } let loop (agent: MailboxProcessor) = @@ -508,19 +716,22 @@ type private FSharpProjectOptionsReactor(checker: FSharpChecker) = | FSharpProjectOptionsMessage.ClearOptions(projectId) -> match cache.TryRemove(projectId) with - | true, (_, _, projectOptions) -> - lastSuccessfulCompilations.TryRemove(projectId) |> ignore - checker.ClearCache([ projectOptions ]) + | true, struct (_, _, projectOptions) -> checker.ClearCache([ projectOptions ]) | _ -> () + lastSuccessfulCompilations.TryRemove(projectId) |> ignore + peReferences.TryRemove projectId |> ignore legacyProjectSites.TryRemove(projectId) |> ignore + clearReferenceWatches projectId | FSharpProjectOptionsMessage.ClearSingleFileOptionsCache(documentId) -> match singleFileCache.TryRemove(documentId) with - | true, (_, _, _, projectOptions, subscription) -> - lastSuccessfulCompilations.TryRemove(documentId.ProjectId) |> ignore + | true, ({ ProjectOptions = projectOptions } as cacheEntry) -> checker.ClearCache([ projectOptions ]) - subscription |> Option.iter (fun handler -> handler.Dispose()) + disposeSingleFileCacheEntry cacheEntry | _ -> () + + clearScriptReferenceWatches documentId + } let agent = @@ -539,7 +750,7 @@ type private FSharpProjectOptionsReactor(checker: FSharpChecker) = agent.Post(FSharpProjectOptionsMessage.ClearSingleFileOptionsCache(documentId)) member _.SetCommandLineOptions(projectId, sourcePaths, options) = - commandLineOptions.[projectId] <- (sourcePaths, options) + commandLineOptions.[projectId] <- struct (sourcePaths, options) member _.SetLegacyProjectSite(projectId, projectSite) = legacyProjectSites.[projectId] <- projectSite @@ -553,21 +764,38 @@ type private FSharpProjectOptionsReactor(checker: FSharpChecker) = commandLineOptions.Clear() legacyProjectSites.Clear() cache.Clear() - singleFileCache.Clear() + // Dispose only the entries we actually removed: the subscription is not idempotent + // and the agent loop disposes what it removes on its own. + for entry in singleFileCache do + match singleFileCache.TryRemove(entry.Key) with + | true, cacheEntry -> disposeSingleFileCacheEntry cacheEntry + | _ -> () + lastSuccessfulCompilations.Clear() + peReferences.Clear() + + for projectId in referenceWatches.Keys |> Array.ofSeq do + clearReferenceWatches projectId + + for documentId in scriptReferenceWatches.Keys |> Array.ofSeq do + clearScriptReferenceWatches documentId member _.ScriptUpdated = scriptUpdatedEvent.Publish + member _.ReferenceStamps = referenceChangeTracker :> IReferenceStamps + interface IDisposable with - member _.Dispose() = + member this.Dispose() = + this.ClearAllCaches() + referenceChangeTracker.Dispose() cancellationTokenSource.Cancel() cancellationTokenSource.Dispose() - (agent :> IDisposable).Dispose() + agent.Dispose() /// Manages mappings of Roslyn workspace Projects/Documents to FCS. -type internal FSharpProjectOptionsManager(checker: FSharpChecker, workspace: Workspace) = +type internal FSharpProjectOptionsManager(checker: FSharpChecker, workspace: Workspace, fileChangeWatcher: IFSharpFileChangeWatcher) = - let reactor = new FSharpProjectOptionsReactor(checker) + let reactor = new FSharpProjectOptionsReactor(checker, fileChangeWatcher) do // We need to listen to this event for lifecycle purposes. @@ -627,7 +855,9 @@ type internal FSharpProjectOptionsManager(checker: FSharpChecker, workspace: Wor match reactor.TryGetCachedOptionsByProjectId(documentId.ProjectId) with | Some(_, parsingOptions, _) -> parsingOptions | _ -> + // ParseFile takes the last entry of SourceFiles as the last compiland; with none it throws. { FSharpParsingOptions.Default with + SourceFiles = [| path |] IsInteractive = CompilerEnvironment.IsScriptFile path } @@ -636,4 +866,6 @@ type internal FSharpProjectOptionsManager(checker: FSharpChecker, workspace: Wor member _.ClearAllCaches() = reactor.ClearAllCaches() + member _.ReferenceStamps = reactor.ReferenceStamps + member _.Checker = checker diff --git a/vsintegration/src/FSharp.Editor/LanguageService/FileChangeWatcher.fs b/vsintegration/src/FSharp.Editor/LanguageService/FileChangeWatcher.fs new file mode 100644 index 00000000000..399f24464ed --- /dev/null +++ b/vsintegration/src/FSharp.Editor/LanguageService/FileChangeWatcher.fs @@ -0,0 +1,493 @@ +// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information. + +namespace Microsoft.VisualStudio.FSharp.Editor + +open System +open System.Collections.Concurrent +open System.Collections.Generic +open System.Collections.Immutable +open System.Threading +open System.Threading.Tasks +open Microsoft.VisualStudio +open Microsoft.VisualStudio.Shell +open Microsoft.VisualStudio.Shell.Interop + +open Microsoft.VisualStudio.FSharp.Editor.DebugHelpers + +open CancellableTasks + +// Push-based file watching for FSharp.Editor, modelled on Roslyn's +// Microsoft.VisualStudio.LanguageServices FileChangeWatcher (which is internal and not +// exposed through ExternalAccess.FSharp). Uses the free-threaded IVsAsyncFileChangeEx2 +// service: subscriptions are batched off the UI thread and callbacks never marshal to it. + +/// A directory to watch recursively (with optional extension filters) so that individual +/// files under it don't each need their own advise cookie. +[] +type internal WatchedDirectory(path: string, extensionFilters: ImmutableArray) = + let path = + if path.EndsWith(string IO.Path.DirectorySeparatorChar, StringComparison.Ordinal) then + path + else + $"{path}{IO.Path.DirectorySeparatorChar}" + + do + for filter in extensionFilters do + if not (filter.StartsWith(".", StringComparison.Ordinal)) then + invalidArg (nameof extensionFilters) $"Filter '{filter}' must start with a period." + + member _.Path = path + member _.ExtensionFilters = extensionFilters + + static member FilePathCoveredByWatchedDirectories(watchedDirectories: ImmutableArray, filePath: string) = + watchedDirectories + |> Seq.exists (fun w -> + filePath.StartsWith(w.Path, StringComparison.OrdinalIgnoreCase) + && (w.ExtensionFilters.IsEmpty + || w.ExtensionFilters + |> Seq.exists (fun filter -> filePath.EndsWith(filter, StringComparison.OrdinalIgnoreCase)))) + +/// A single watched file; disposing stops watching. +type internal IFSharpWatchedFile = + inherit IDisposable + +/// A group of file/directory watches sharing one event sink. Disposing unsubscribes everything. +type internal IFSharpFileChangeContext = + inherit IDisposable + + [] + abstract FileChanged: IEvent + + /// Starts watching a file without waiting for the OS registration. No-op (but still valid + /// to dispose) when the path is already covered by one of the context's watched directories. + abstract EnqueueWatchingFile: filePath: string -> IFSharpWatchedFile + +type internal IFSharpFileChangeWatcher = + abstract CreateContext: watchedDirectories: ImmutableArray -> IFSharpFileChangeContext + +/// Last-write stamps of watched reference files; a path nobody watches is stat'd directly. +type internal IReferenceStamps = + abstract GetLastWriteTimeUtc: fullFilePath: string -> DateTime + abstract Invalidate: fullFilePath: string -> unit + +type private WatchedReference = + { + Token: IFSharpWatchedFile + mutable Count: int + mutable Stamp: DateTime voption + } + +[] +module private FileChangeWatcherImpl = + + // Same flags Roslyn uses for both subscribing and filtering callbacks. + let watchFlags = + _VSFILECHANGEFLAGS.VSFILECHG_Size ||| _VSFILECHANGEFLAGS.VSFILECHG_Time + + let relevantFlags = + _VSFILECHANGEFLAGS.VSFILECHG_Time + ||| _VSFILECHANGEFLAGS.VSFILECHG_Add + ||| _VSFILECHANGEFLAGS.VSFILECHG_Del + ||| _VSFILECHANGEFLAGS.VSFILECHG_Size + + /// Empirically strong batching window during high activity (solution open/close); see + /// Roslyn's FileChangeWatcher. + let defaultBatchingDelay = TimeSpan.FromMilliseconds 500. + + /// Delay between the last observed change to a path and the callback: a rebuild typically + /// writes a temp file then renames, producing several rapid notifications. + let defaultNotificationDelay = TimeSpan.FromSeconds 2. + + let noOpWatchedFile = + { new IFSharpWatchedFile with + member _.Dispose() = () + } + +[] +type internal FSharpWatchedFileToken() = + member val Cookie: uint32 voption = ValueNone with get, set + +/// Subscription operations queued for batched application against the file change service. +type private WatcherOperation = + | WatchDir of path: string * filters: ImmutableArray * sink: IVsFreeThreadedFileChangeEvents2 * cookies: List + | WatchFiles of paths: string list * tokens: FSharpWatchedFileToken list * sink: IVsFreeThreadedFileChangeEvents2 + | UnwatchFiles of tokens: FSharpWatchedFileToken list + | UnwatchDirs of cookies: List + +[] +type internal FSharpFileChangeWatcher(fileChangeService: Task, batchingDelay: TimeSpan) = + + let applyBatch (service: IVsAsyncFileChangeEx2) (ops: WatcherOperation list) = + cancellableTask { + let! ct = CancellableTask.getCancellationToken () + + // Coalesce adjacent same-kind operations into single service calls, preserving order + // between kinds (a watch enqueued before an unwatch must be applied first). + let mutable pending = ops + + while not pending.IsEmpty do + match pending with + | [] -> () + | WatchDir(path, filters, sink, cookies) :: rest -> + pending <- rest + let! cookie = service.AdviseDirChangeAsync(path, true, sink, ct) + cookies.Add cookie + + if not filters.IsEmpty then + do! service.FilterDirectoryChangesAsync(cookie, Seq.toArray filters, ct) + + | WatchFiles(_, _, sink) :: _ -> + let batch = + pending + |> Seq.takeWhile (function + | WatchFiles(_, _, s) -> obj.ReferenceEquals(s, sink) + | _ -> false) + |> Seq.toArray + + pending <- pending |> List.skip batch.Length + + let paths = + [| + for op in batch do + match op with + | WatchFiles(p, _, _) -> yield! p + | _ -> () + |] + + let tokens = + [| + for op in batch do + match op with + | WatchFiles(_, t, _) -> yield! t + | _ -> () + |] + + let! cookies = service.AdviseFileChangesAsync(paths, watchFlags, sink, ct) + + (tokens, cookies) + ||> Array.iter2 (fun token cookie -> token.Cookie <- ValueSome cookie) + + | UnwatchFiles _ :: _ -> + let batch = + pending + |> Seq.takeWhile (function + | UnwatchFiles _ -> true + | _ -> false) + |> Seq.toArray + + pending <- pending |> List.skip batch.Length + + // A token whose watch never got a cookie (or was already unadvised) is a no-op. + let cookies = + [| + for op in batch do + match op with + | UnwatchFiles tokens -> + for token in tokens do + match token.Cookie with + | ValueSome cookie -> + token.Cookie <- ValueNone + cookie + | ValueNone -> () + | _ -> () + |] + + if cookies.Length > 0 then + let! _ = service.UnadviseFileChangesAsync(cookies, ct) + () + + | UnwatchDirs cookies :: rest -> + pending <- rest + + if cookies.Count > 0 then + let! _ = service.UnadviseDirChangesAsync(cookies.ToArray(), ct) + () + } + + let cancellationTokenSource = new CancellationTokenSource() + + // Single consumer loop: waits for the first queued operation, sleeps out the batching + // window, drains the queue and applies everything in one pass. Nothing ever blocks on the + // service being available. + let agent = + MailboxProcessor + .Start( + (fun inbox -> + async { + let! ct = Async.CancellationToken + + while true do + try + let! first = inbox.Receive() + do! Async.Sleep(int batchingDelay.TotalMilliseconds) + + let ops = ResizeArray [ first ] + + while inbox.CurrentQueueLength > 0 do + let! op = inbox.Receive() + ops.Add op + + let! service = fileChangeService |> Async.AwaitTask + + do! + applyBatch service (List.ofSeq ops) + |> CancellableTask.startAsTask ct + |> Async.AwaitTask + with ex when not (ex :? OperationCanceledException) -> + // Never let a failed advise/unadvise (e.g. non-existent path) kill the + // subscription loop; we simply won't get events for that path. + FSharpOutputPane.logExceptionWithContext (ex, nameof FSharpFileChangeWatcher) + }), + cancellationTokenSource.Token + ) + + new(fileChangeService) = new FSharpFileChangeWatcher(fileChangeService, defaultBatchingDelay) + + member private _.Enqueue(op: WatcherOperation) = agent.Post op + + /// Production factory: obtains SVsFileChangeEx asynchronously without blocking any + /// background thread on UI-thread availability. + static member CreateDefaultServiceTask() = + task { + let! service = AsyncServiceProvider.GlobalProvider.GetServiceAsync(typeof) + return service :?> IVsAsyncFileChangeEx2 + } + + interface IFSharpFileChangeWatcher with + member _.CreateContext(watchedDirectories) = + new FileChangeContext(agent.Post, watchedDirectories) :> IFSharpFileChangeContext + + interface IDisposable with + member _.Dispose() = + cancellationTokenSource.Cancel() + cancellationTokenSource.Dispose() + agent.Dispose() + +and [] private FileChangeContext(enqueue: WatcherOperation -> unit, watchedDirectories: ImmutableArray) as this = + + let gate = obj () + let mutable disposed = false + let activeFileTokens = HashSet() + let directoryCookies = List() + let fileChanged = Event() + + let raiseChanges (count: uint32) (files: string[]) (changeFlags: uint32[]) = + for i in 0 .. int count - 1 do + if + (enum<_VSFILECHANGEFLAGS> (int changeFlags[i]) &&& relevantFlags) + <> enum<_VSFILECHANGEFLAGS> 0 + then + fileChanged.Trigger files[i] + + VSConstants.S_OK + + do + for watchedDirectory in watchedDirectories do + enqueue ( + WatchDir( + watchedDirectory.Path, + watchedDirectory.ExtensionFilters, + this :> IVsFreeThreadedFileChangeEvents2, + directoryCookies + ) + ) + + member private _.StopWatchingFile(token: FSharpWatchedFileToken) = + lock gate (fun () -> activeFileTokens.Remove token |> ignore) + enqueue (UnwatchFiles [ token ]) + + interface IFSharpFileChangeContext with + [] + member _.FileChanged = fileChanged.Publish + + member _.EnqueueWatchingFile filePath = + if WatchedDirectory.FilePathCoveredByWatchedDirectories(watchedDirectories, filePath) then + noOpWatchedFile + else + let token = FSharpWatchedFileToken() + lock gate (fun () -> activeFileTokens.Add token |> ignore) + enqueue (WatchFiles([ filePath ], [ token ], this :> IVsFreeThreadedFileChangeEvents2)) + + { new IFSharpWatchedFile with + member _.Dispose() = this.StopWatchingFile token + } + + interface IDisposable with + member _.Dispose() = + let alreadyDisposed = + lock gate (fun () -> + let d = disposed + disposed <- true + d) + + if not alreadyDisposed then + enqueue (UnwatchDirs directoryCookies) + enqueue (UnwatchFiles(lock gate (fun () -> List.ofSeq activeFileTokens))) + + // Free-threaded sink: callbacks arrive on background threads and stay there. + interface IVsFreeThreadedFileChangeEvents2 with + member _.FilesChanged(cChanges, rgpszFile, rggrfChange) = + raiseChanges cChanges rgpszFile rggrfChange + + member _.DirectoryChanged _ = VSConstants.E_NOTIMPL + member _.DirectoryChangedEx(_, _) = VSConstants.E_NOTIMPL + + member _.DirectoryChangedEx2(_, cChanges, rgpszFile, rggrfChange) = + raiseChanges cChanges rgpszFile rggrfChange + + interface IVsFreeThreadedFileChangeEvents with + member _.FilesChanged(cChanges, rgpszFile, rggrfChange) = + raiseChanges cChanges rgpszFile rggrfChange + + member _.DirectoryChanged _ = VSConstants.E_NOTIMPL + member _.DirectoryChangedEx(_, _) = VSConstants.E_NOTIMPL + + interface IVsFileChangeEvents with + member _.FilesChanged(cChanges, rgpszFile, rggrfChange) = + raiseChanges cChanges rgpszFile rggrfChange + + member _.DirectoryChanged _ = VSConstants.E_NOTIMPL + +/// Ref-counted, debounced watching of reference assemblies (or any other off-workspace files), +/// modelled on Roslyn's ReferenceFileChangeTracker. Multiple projects watching the same dll +/// share one subscription; bursts of writes produce a single callback per path. The last-write +/// stamp of a path lives inside its watch entry, so a cached stamp is only ever served while a +/// change notification can still reach it. +[] +type internal FSharpReferenceChangeTracker(watcher: IFSharpFileChangeWatcher, onChanged: string -> unit, notificationDelay: TimeSpan) = + + let gate = obj () + let mutable disposed = false + + let watchedFiles = + Dictionary(StringComparer.OrdinalIgnoreCase) + + let pendingTimers = Dictionary(StringComparer.OrdinalIgnoreCase) + + // On each platform there is a place framework reference assemblies live; these rarely change + // but account for most watched paths, so cover them with directory watches up front. + static let defaultWatchedDirectories () = + let dotnetRoot = Environment.GetEnvironmentVariable "DOTNET_ROOT" + let nugetPackages = Environment.GetEnvironmentVariable "NUGET_PACKAGES" + + let directories = + seq { + if not (String.IsNullOrEmpty dotnetRoot) then + IO.Path.Combine(dotnetRoot, "packs") + + IO.Path.Combine(Environment.GetFolderPath Environment.SpecialFolder.ProgramFiles, "dotnet", "packs") + + IO.Path.Combine( + Environment.GetFolderPath Environment.SpecialFolder.ProgramFilesX86, + "Reference Assemblies", + "Microsoft", + "Framework" + ) + + if String.IsNullOrEmpty nugetPackages then + IO.Path.Combine(Environment.GetFolderPath Environment.SpecialFolder.UserProfile, ".nuget", "packages") + else + nugetPackages + } + |> Seq.distinct + |> Seq.map (fun d -> WatchedDirectory(d, ImmutableArray.Create ".dll")) + + directories.ToImmutableArray() + + let context = + lazy + (let ctx = watcher.CreateContext(defaultWatchedDirectories ()) + + ctx.FileChanged.Add(fun path -> + let fire (_: obj) = + let isWatched = + lock gate (fun () -> + match pendingTimers.TryGetValue path with + | true, timer -> + pendingTimers.Remove path |> ignore + timer.Dispose() + | _ -> () + + watchedFiles.ContainsKey path) + + if isWatched then + onChanged path + + lock gate (fun () -> + // Directory watches cover whole trees; only debounce paths someone watches. + match watchedFiles.TryGetValue path with + | true, entry when not disposed -> + entry.Stamp <- ValueNone + + let timer = + match pendingTimers.TryGetValue path with + | true, timer -> timer + | _ -> + let timer = new Timer(fire, null, Timeout.Infinite, Timeout.Infinite) + pendingTimers[path] <- timer + timer + + timer.Change(notificationDelay, Timeout.InfiniteTimeSpan) |> ignore + | _ -> ())) + + ctx) + + new(watcher, onChanged) = new FSharpReferenceChangeTracker(watcher, onChanged, defaultNotificationDelay) + + /// Starts watching a path, ref-counted. Call StopWatchingReference exactly once per start. + member _.StartWatchingReference(fullFilePath: string) = + lock gate (fun () -> + if not disposed then + match watchedFiles.TryGetValue fullFilePath with + | true, entry -> entry.Count <- entry.Count + 1 + | _ -> + watchedFiles[fullFilePath] <- + { + Token = context.Value.EnqueueWatchingFile fullFilePath + Count = 1 + Stamp = ValueNone + }) + + member _.StopWatchingReference(fullFilePath: string) = + lock gate (fun () -> + if not disposed then + match watchedFiles.TryGetValue fullFilePath with + | true, { Count = 1; Token = token } -> + watchedFiles.Remove fullFilePath |> ignore + token.Dispose() + | true, entry -> entry.Count <- entry.Count - 1 + | _ -> ()) + + interface IReferenceStamps with + member _.GetLastWriteTimeUtc fullFilePath = + lock gate (fun () -> + match watchedFiles.TryGetValue fullFilePath with + | true, { Stamp = ValueSome stamp } -> stamp + | true, entry -> + let stamp = IO.File.GetLastWriteTimeUtc fullFilePath + entry.Stamp <- ValueSome stamp + stamp + | _ -> IO.File.GetLastWriteTimeUtc fullFilePath) + + member _.Invalidate fullFilePath = + lock gate (fun () -> + match watchedFiles.TryGetValue fullFilePath with + | true, entry -> entry.Stamp <- ValueNone + | _ -> ()) + + member _.Dispose() = + lock gate (fun () -> + if not disposed then + disposed <- true + watchedFiles.Clear() + + for KeyValue(_, timer) in pendingTimers do + timer.Dispose() + + pendingTimers.Clear() + + if context.IsValueCreated then + context.Value.Dispose()) + + interface IDisposable with + member this.Dispose() = this.Dispose() diff --git a/vsintegration/src/FSharp.Editor/LanguageService/IProjectSite.fs b/vsintegration/src/FSharp.Editor/LanguageService/IProjectSite.fs index 57b530309ad..611e3204b62 100644 --- a/vsintegration/src/FSharp.Editor/LanguageService/IProjectSite.fs +++ b/vsintegration/src/FSharp.Editor/LanguageService/IProjectSite.fs @@ -25,7 +25,7 @@ and internal IProjectSite = abstract CompilationReferences: string[] /// The '-o:' output bin path, without the '-o:' - abstract CompilationBinOutputPath: string option + abstract CompilationBinOutputPath: string voption /// The name of the project file. abstract ProjectFileName: string diff --git a/vsintegration/src/FSharp.Editor/LanguageService/LanguageService.fs b/vsintegration/src/FSharp.Editor/LanguageService/LanguageService.fs index 427baf0c6ab..dabe74fafbd 100644 --- a/vsintegration/src/FSharp.Editor/LanguageService/LanguageService.fs +++ b/vsintegration/src/FSharp.Editor/LanguageService/LanguageService.fs @@ -6,27 +6,31 @@ open System open System.ComponentModel.Design open System.Runtime.InteropServices open System.Threading +open System.Threading.Tasks open System.IO open System.Collections.Immutable open Microsoft.CodeAnalysis +open Microsoft.CodeAnalysis.ExternalAccess.FSharp +open Microsoft.CodeAnalysis.Host.Mef open Microsoft.CodeAnalysis.Options -open FSharp.Compiler -open FSharp.Compiler.CodeAnalysis -open FSharp.NativeInterop +open Microsoft.ServiceHub.Framework open Microsoft.VisualStudio +open Microsoft.VisualStudio.Copilot open Microsoft.VisualStudio.FSharp.Editor open Microsoft.VisualStudio.LanguageServices open Microsoft.VisualStudio.LanguageServices.Implementation.LanguageService open Microsoft.VisualStudio.LanguageServices.Implementation.ProjectSystem open Microsoft.VisualStudio.Shell open Microsoft.VisualStudio.Shell.Interop +open Microsoft.VisualStudio.Shell.ServiceBroker open Microsoft.VisualStudio.Text.Outlining -open Microsoft.CodeAnalysis.ExternalAccess.FSharp -open Microsoft.CodeAnalysis.Host.Mef +open Microsoft.VisualStudio.Editor open Microsoft.VisualStudio.FSharp.Editor.Telemetry -open CancellableTasks +open FSharp.Compiler +open FSharp.Compiler.CodeAnalysis +open FSharp.NativeInterop open FSharp.Compiler.Text -open Microsoft.VisualStudio.Editor +open CancellableTasks #nowarn "9" // NativePtr.toNativeInt #nowarn "57" // Experimental stuff @@ -205,7 +209,11 @@ type internal FSharpWorkspaceServiceFactory |> CancellableTask.startAsTask CancellationToken.None |> ignore) - let optionsManager = FSharpProjectOptionsManager(checker, workspace) + let fileChangeWatcher = + new FSharpFileChangeWatcher(FSharpFileChangeWatcher.CreateDefaultServiceTask()) + + let optionsManager = + FSharpProjectOptionsManager(checker, workspace, fileChangeWatcher) { new IFSharpWorkspaceService with member _.Checker = checker @@ -408,8 +416,10 @@ type internal FSharpPackage() as this = |> CancellableTask.startAsTask cancellationToken) ) + override this.RegisterOnAfterPackageLoadedAsyncWork(afterPackageLoadedTasks: PackageLoadTasks) = + base.RegisterOnAfterPackageLoadedAsyncWork(afterPackageLoadedTasks) + #if DEBUG - override _.RegisterOnAfterPackageLoadedAsyncWork(afterPackageLoadedTasks: PackageLoadTasks) = afterPackageLoadedTasks.AddTask( false, fun _ _ -> @@ -421,6 +431,62 @@ type internal FSharpPackage() as this = ) #endif + /// Copilot's registration service is an exported brokered service whose MEF part constructor blocks waiting + /// for the main thread. Asking for the proxy from a background thread therefore deadlocks against anyone + /// asking for it from the main thread - the Git provider does, while creating its services at solution open - + /// so take the main thread dependency deliberately, the way Roslyn does for a proxy that has one. + member private this.RegisterCopilotContextProviderAsync(cancellationToken: CancellationToken) : Task = + task { + try + DebugHelpers.FSharpOutputPane.logInfo "Copilot: registering context provider (switching to main thread)…" + do! this.JoinableTaskFactory.SwitchToMainThreadAsync(alwaysYield = true, cancellationToken = cancellationToken) + + DebugHelpers.FSharpOutputPane.logInfo "Copilot: getting brokered service container…" + let! container = this.GetServiceAsync(typeof) + + match container with + | :? IBrokeredServiceContainer as container -> + // The Interactions service also serves the registration interface. It is absent when + // GitHub Copilot is not installed, in which case the proxy is null and F# stays out of the picker. + DebugHelpers.FSharpOutputPane.logInfo "Copilot: getting registration service proxy…" + + let! registration = + container + .GetFullAccessServiceBroker() + .GetProxyAsync(CopilotDescriptors.InteractionService, cancellationToken) + + use registration = registration + + match registration with + | null -> DebugHelpers.FSharpOutputPane.logInfo "Copilot: service proxy is null (Copilot not installed)" + | registration -> + DebugHelpers.FSharpOutputPane.logInfo "Copilot: registering F# context provider…" + + let moniker = + ServiceMoniker( + FSharpConstants.copilotSymbolProviderName, + Version CopilotDescriptors.CurrentContextProviderVersion + ) + + do! registration.RegisterContextProviderAsync(moniker, cancellationToken) + DebugHelpers.FSharpOutputPane.logInfo "Copilot: registration complete" + | _ -> DebugHelpers.FSharpOutputPane.logInfo "Copilot: container is not IBrokeredServiceContainer" + // A Copilot failure - a contract version the installed build does not serve, say - must not take the + // rest of the post-load work down with it. + with ex when not (ex :? OperationCanceledException) -> + DebugHelpers.FSharpOutputPane.logExceptionWithContext (ex, "Registering the Copilot context provider") + } + + override this.LoadComponentsInBackgroundAfterSolutionFullyLoadedAsync(cancellationToken) : Task = + // 'base' cannot be captured by the state machine, so start the base work before entering it. + let baseComponents = + base.LoadComponentsInBackgroundAfterSolutionFullyLoadedAsync(cancellationToken) + + task { + do! baseComponents + do! this.RegisterCopilotContextProviderAsync(cancellationToken) + } + override _.RoslynLanguageName = FSharpConstants.FSharpLanguageName (*override this.CreateWorkspace() = this.ComponentModel.GetService() *) override this.CreateLanguageService() = FSharpLanguageService(this) diff --git a/vsintegration/src/FSharp.Editor/LanguageService/LegacyProjectWorkspaceMap.fs b/vsintegration/src/FSharp.Editor/LanguageService/LegacyProjectWorkspaceMap.fs index 12e2554284f..ef55cea1458 100644 --- a/vsintegration/src/FSharp.Editor/LanguageService/LegacyProjectWorkspaceMap.fs +++ b/vsintegration/src/FSharp.Editor/LanguageService/LegacyProjectWorkspaceMap.fs @@ -117,7 +117,7 @@ type internal LegacyProjectWorkspaceMap optionsAssociation.Add(projectContext, updatedOptions) - projectContext.BinOutputPath <- Option.toObj site.CompilationBinOutputPath + projectContext.BinOutputPath <- ValueOption.toObj site.CompilationBinOutputPath let info = (updatedFiles, updatedRefs) legacyProjectLookup.AddOrUpdate(projectId, info, (fun _ _ -> info)) |> ignore @@ -142,7 +142,7 @@ type internal LegacyProjectWorkspaceMap projectFileName, projectGuid, hierarchy, - Option.toObj site.CompilationBinOutputPath + ValueOption.toObj site.CompilationBinOutputPath ) legacyProjectIdLookup.[projectGuid] <- projectContext.Id diff --git a/vsintegration/src/FSharp.Editor/LanguageService/MetadataAsSource.fs b/vsintegration/src/FSharp.Editor/LanguageService/MetadataAsSource.fs index 0d21ee0b358..f91d655c111 100644 --- a/vsintegration/src/FSharp.Editor/LanguageService/MetadataAsSource.fs +++ b/vsintegration/src/FSharp.Editor/LanguageService/MetadataAsSource.fs @@ -159,19 +159,21 @@ type FSharpMetadataAsSourceService() = let projsArr = projs.ToArray() projsArr |> Array.iter (fun pair -> clear pair.Key pair.Value) - member _.ShowDocument(projInfo: ProjectInfo, filePath: string, text: Text.SourceText) = - match projInfo.Documents |> Seq.tryFindV (fun doc -> doc.FilePath = filePath) with - | ValueSome document -> - let _ = - let directoryName = Path.GetDirectoryName(filePath) + /// Puts the generated signature on disk, where opening it reads it from. Touches no workspace, + /// so the caller can do it before taking the main thread. + member _.WriteDocument(filePath: string, text: Text.SourceText) = + let directoryName = Path.GetDirectoryName filePath - if Directory.Exists(directoryName) |> not then - Directory.CreateDirectory(directoryName) |> ignore + if Directory.Exists directoryName |> not then + Directory.CreateDirectory directoryName |> ignore - use fileStream = new FileStream(filePath, IO.FileMode.Create) - use writer = new StreamWriter(fileStream) - text.Write(writer) + use fileStream = new FileStream(filePath, IO.FileMode.Create) + use writer = new StreamWriter(fileStream) + text.Write writer + member _.ShowDocument(projInfo: ProjectInfo, filePath: string) = + match projInfo.Documents |> Seq.tryFindV (fun doc -> doc.FilePath = filePath) with + | ValueSome document -> let projectFile = Path.ChangeExtension(filePath, "fsproj") let projectContext = createMetadataProjectContext projectFile projInfo document diff --git a/vsintegration/src/FSharp.Editor/LanguageService/SymbolHelpers.fs b/vsintegration/src/FSharp.Editor/LanguageService/SymbolHelpers.fs index 36319820f80..d213fa12f88 100644 --- a/vsintegration/src/FSharp.Editor/LanguageService/SymbolHelpers.fs +++ b/vsintegration/src/FSharp.Editor/LanguageService/SymbolHelpers.fs @@ -7,6 +7,7 @@ open System.Collections.Concurrent open System.Collections.Generic open System.Collections.Immutable open System.IO +open System.Threading open System.Threading.Tasks open Microsoft.CodeAnalysis @@ -70,34 +71,107 @@ module internal SymbolHelpers = return symbolUses } - let getSymbolUsesInProjects (symbol: FSharpSymbol, projects: Project list, onFound: Document -> range -> CancellableTask) = - match projects with + /// Ranks the target-framework instances of a project file: the one the current document lives in, + /// then those in its dependency closure, whose references resolve the symbol the same way. + let private rankInstances (currentProject: Project) = + let graph = currentProject.Solution.GetProjectDependencyGraph() + + let related = + HashSet + [ + yield! graph.GetProjectsThatThisProjectTransitivelyDependsOn currentProject.Id + yield! graph.GetProjectsThatTransitivelyDependOnThisProject currentProject.Id + ] + + fun (project: Project) -> + if project.Id = currentProject.Id then 0 + elif related.Contains project.Id then 1 + else 2 + + /// One search per project file: the best-ranked target-framework instance is searched in full, + /// the others only where their sources can differ from it. + let private groupInstances (currentProject: Project) (projects: Project seq) = + let rank = rankInstances currentProject + + seq { + for _, instances in projects |> Seq.groupBy _.FilePath do + let instances = Seq.toArray instances + // Ordering the rest buys nothing, and ranking every instance of the solution at once + // measures slower than one pass per group. + let primary = Array.minBy rank instances + struct (primary, instances |> Seq.filter (fun instance -> instance.Id <> primary.Id)) + } + + /// One core is left to the thread that has to stay responsive: every search shares this budget, + /// including the C# and Visual Basic searches that run alongside the F# ones. + let searchThrottle = new SemaphoreSlim(max 1 (Environment.ProcessorCount - 1)) + + let getSymbolUsesInProjects + (symbol: FSharpSymbol, currentProject: Project, projects: Project list, onFound: Document -> range seq -> CancellableTask) + = + match projects |> List.filter _.IsFSharp with | [] -> CancellableTask.singleton () - | firstProject :: _ -> + | firstProject :: _ as projects -> let isFastFindReferencesEnabled = firstProject.IsFastFindReferencesEnabled // TODO: this needs to use already boxed boolean instead of boxing it every time. let props = [| nameof isFastFindReferencesEnabled, isFastFindReferencesEnabled :> obj |] + let groups = + if isFastFindReferencesEnabled then + groupInstances currentProject projects + else + seq { for project in projects -> struct (project, Seq.empty) } + cancellableTask { // TODO: this needs to be a single event with a duration TelemetryReporter.ReportSingleEvent(TelemetryEvents.GetSymbolUsesInProjectsStarted, props) + let! ct = CancellableTask.getCancellationToken () + + // A file that several projects compile - the target-framework instances of one project + // file, or two project files sharing a source file - is searched in each of them and + // reports the same range every time. The range carries its file, so the first project + // to report a use keeps it and the rest are dropped. + let reported = ConcurrentDictionary() + + let onFound document ranges = + let fresh = + ranges |> Seq.filter (fun range -> reported.TryAdd(range, ())) |> Seq.toArray + + if fresh.Length = 0 then + CancellableTask.singleton () + else + onFound document fresh + // Mutated by the checker while a snapshot is built, so snapshots are built one at a time. let snapshotAccumulator = Dictionary() + let searches = ResizeArray() - let! projects = - projects - |> Seq.map (fun project -> - project.GetFSharpProjectSnapshot(snapshotAccumulator) - |> CancellableTask.map (fun s -> project, s)) - |> CancellableTask.sequential + let snapshotFor (project: Project) = + if project.UseTransparentCompiler then + project.GetFSharpProjectSnapshot snapshotAccumulator + |> CancellableTask.map ValueSome + else + CancellableTask.singleton ValueNone - do! - projects - |> Seq.map (fun (project, snapshot) -> - project.FindFSharpReferencesAsync(symbol, snapshot, onFound, "getSymbolUsesInProjects")) - |> CancellableTask.whenAll + // Started, not awaited: the next project's snapshot is built while this one searches. + let startSearching (project: Project) snapshot searchedInstance = + searches.Add( + project.FindFSharpReferencesAsync + (symbol, snapshot, searchedInstance, searchThrottle, onFound, "getSymbolUsesInProjects") + ct + ) + + for struct (primary, secondaries) in groups do + let! snapshot = snapshotFor primary + startSearching primary snapshot ValueNone + + for secondary in secondaries do + let! snapshot = snapshotFor secondary + startSearching secondary snapshot (ValueSome primary) + + do! Task.WhenAll searches TelemetryReporter.ReportSingleEvent(TelemetryEvents.GetSymbolUsesInProjectsFinished, props) } @@ -106,7 +180,7 @@ module internal SymbolHelpers = (symbolUse: FSharpSymbolUse) (currentDocument: Document) (checkFileResults: FSharpCheckFileResults) - (onFound: Document -> range -> CancellableTask) + (onFound: Document -> range seq -> CancellableTask) = cancellableTask { match symbolUse.GetSymbolScope currentDocument with @@ -115,16 +189,13 @@ module internal SymbolHelpers = let symbolUses = checkFileResults.GetUsesOfSymbolInFile(symbolUse.Symbol, relatedSymbolKinds = RelatedSymbolUseKind.All) - do! - symbolUses - |> Seq.map (fun symbolUse -> onFound currentDocument symbolUse.Range) - |> CancellableTask.whenAll + do! onFound currentDocument (symbolUses |> Seq.map _.Range) | Some SymbolScope.SignatureAndImplementation -> let otherFile = getOtherFile currentDocument.FilePath let! otherFileCheckResults = - match currentDocument.Project.Solution.TryGetDocumentFromPath otherFile with + match currentDocument.TryGetSolutionDocumentFromPath otherFile with | ValueSome doc -> cancellableTask { let! _, checkFileResults = doc.GetFSharpParseAndCheckResultsAsync("findReferencedSymbolsAsync") @@ -132,52 +203,49 @@ module internal SymbolHelpers = } | ValueNone -> CancellableTask.singleton [] - let symbolUses = - (checkFileResults, currentDocument) :: otherFileCheckResults - |> Seq.collect (fun (checkFileResults, doc) -> + for checkFileResults, doc in (checkFileResults, currentDocument) :: otherFileCheckResults do + let symbolUses = checkFileResults.GetUsesOfSymbolInFile(symbolUse.Symbol, relatedSymbolKinds = RelatedSymbolUseKind.All) - |> Seq.map (fun symbolUse -> (doc, symbolUse.Range))) - do! symbolUses |> Seq.map ((<||) onFound) |> CancellableTask.whenAll + do! onFound doc (symbolUses |> Seq.map _.Range) - | scope -> + | Some(SymbolScope.Projects(scopeProjects, isLocalForProject)) -> let projectsToCheck = - match scope with - | Some(SymbolScope.CurrentDocument) - | Some(SymbolScope.SignatureAndImplementation) -> - // For current document or signature/implementation, just search current project - [ currentDocument.Project ] - | Some(SymbolScope.Projects(scopeProjects, false)) -> + if isLocalForProject then + scopeProjects + else [ for scopeProject in scopeProjects do yield scopeProject yield! scopeProject.GetDependentProjects() ] |> List.distinct - | Some(SymbolScope.Projects(scopeProjects, true)) -> scopeProjects - // The symbol is declared in .NET framework, an external assembly or in a C# project within the solution. - // Optimization: Only search projects that reference the specific assembly - | None -> - match symbolUse.Symbol.Assembly.FileName with - | Some assemblyPath -> - let referencingProjects = - ProjectFiltering.getProjectsReferencingAssembly assemblyPath currentDocument.Project.Solution - - if List.isEmpty referencingProjects then - Seq.toList currentDocument.Project.Solution.Projects - else - referencingProjects - | None -> Seq.toList currentDocument.Project.Solution.Projects - - do! getSymbolUsesInProjects (symbolUse.Symbol, projectsToCheck, onFound) + + do! getSymbolUsesInProjects (symbolUse.Symbol, currentDocument.Project, projectsToCheck, onFound) + + // The symbol is declared in .NET framework, an external assembly or in a C# project within the solution. + // Optimization: Only search projects that reference the specific assembly + | None -> + let projectsToCheck = + match symbolUse.Symbol.Assembly.FileName with + | Some assemblyPath -> + match ProjectFiltering.getProjectsReferencingAssembly assemblyPath currentDocument.Project.Solution with + | [] -> Seq.toList currentDocument.Project.Solution.Projects + | referencingProjects -> referencingProjects + | None -> Seq.toList currentDocument.Project.Solution.Projects + + do! getSymbolUsesInProjects (symbolUse.Symbol, currentDocument.Project, projectsToCheck, onFound) } let getSymbolUses (symbolUse: FSharpSymbolUse) (currentDocument: Document) (checkFileResults: FSharpCheckFileResults) = cancellableTask { let symbolUses = ConcurrentBag() - let onFound = - fun document range -> cancellableTask { symbolUses.Add(document, range) } + let onFound document (ranges: range seq) = + cancellableTask { + for range in ranges do + symbolUses.Add(document, range) + } do! findSymbolUses symbolUse currentDocument checkFileResults onFound diff --git a/vsintegration/src/FSharp.Editor/LanguageService/Symbols.fs b/vsintegration/src/FSharp.Editor/LanguageService/Symbols.fs index 19e446f2d08..9c14f6d58fc 100644 --- a/vsintegration/src/FSharp.Editor/LanguageService/Symbols.fs +++ b/vsintegration/src/FSharp.Editor/LanguageService/Symbols.fs @@ -1,6 +1,7 @@ [] module internal Microsoft.VisualStudio.FSharp.Editor.Symbols +open System open System.IO open Microsoft.CodeAnalysis open FSharp.Compiler.CodeAnalysis @@ -35,6 +36,25 @@ type FSharpSymbol with | :? FSharpField -> not publicOrInternal | _ -> false + /// The documentation comment id of the symbol's compiled form, as C# and VB compilations resolve it. + member this.DocumentationCommentId = + let xmlDocSig = + match this with + | :? FSharpMemberOrFunctionOrValue as value -> + match value.XmlDocSig with + // A literal compiles to a field, which Roslyn names F: where FCS says P:. + | docSig when value.LiteralValue.IsSome && docSig.StartsWith("P:", StringComparison.Ordinal) -> $"F:{docSig.Substring 2}" + | docSig -> docSig + | :? FSharpEntity as entity -> entity.XmlDocSig + | :? FSharpField as field -> field.XmlDocSig + | :? FSharpUnionCase as unionCase -> unionCase.XmlDocSig + | _ -> "" + + if String.IsNullOrEmpty xmlDocSig then + ValueNone + else + ValueSome xmlDocSig + type FSharpSymbolUse with member this.GetSymbolScope(currentDocument: Document) : SymbolScope option = @@ -64,11 +84,9 @@ type FSharpSymbolUse with Some(SymbolScope.Projects([ currentDocument.Project ], isSymbolLocalForProject)) else let projects = - currentDocument.Project.Solution.GetDocumentIdsWithFilePath(filePath) - |> Seq.map (fun x -> x.ProjectId) - |> Seq.distinct - |> Seq.map currentDocument.Project.Solution.GetProject - |> Seq.toList + currentDocument.GetSolutionDocumentsWithFilePath filePath + |> List.map _.Project + |> List.distinctBy _.Id match projects with | [] -> None diff --git a/vsintegration/src/FSharp.Editor/LanguageService/WorkspaceExtensions.fs b/vsintegration/src/FSharp.Editor/LanguageService/WorkspaceExtensions.fs index 2406f3a6e32..bb0120199a5 100644 --- a/vsintegration/src/FSharp.Editor/LanguageService/WorkspaceExtensions.fs +++ b/vsintegration/src/FSharp.Editor/LanguageService/WorkspaceExtensions.fs @@ -2,7 +2,9 @@ module internal Microsoft.VisualStudio.FSharp.Editor.WorkspaceExtensions open System +open System.Collections.Generic open System.Runtime.CompilerServices +open System.Threading open Microsoft.CodeAnalysis open Microsoft.VisualStudio.FSharp.Editor @@ -11,6 +13,8 @@ open FSharp.Compiler open FSharp.Compiler.CodeAnalysis open FSharp.Compiler.CodeAnalysis.ProjectSnapshot open FSharp.Compiler.Symbols +open FSharp.Compiler.Syntax +open FSharp.Compiler.SyntaxTrivia open FSharp.Compiler.BuildGraph open CancellableTasks @@ -24,14 +28,22 @@ open System.Text.Json.Nodes #nowarn "57" // Experimental stuff +/// Everything the checker needs for one Roslyn project, resolved once and cached per project. +type internal FSharpCompilationOptions = + { + Checker: FSharpChecker + OptionsManager: FSharpProjectOptionsManager + ParsingOptions: FSharpParsingOptions + ProjectOptions: FSharpProjectOptions + } + [] module internal ProjectCache = /// This is a cache to maintain FSharpParsingOptions and FSharpProjectOptions per Roslyn Project. /// The Roslyn Project is held weakly meaning when it is cleaned up by the GC, the FSharParsingOptions and FSharpProjectOptions will be cleaned up by the GC. /// At some point, this will be the main caching mechanism for FCS projects instead of FCS itself. - let Projects = - ConditionalWeakTable() + let Projects = ConditionalWeakTable() module internal SolutionConfigCache = @@ -170,12 +182,12 @@ module private CheckerExtensions = let exist xs = xs |> Seq.isEmpty |> not - let getFSharpOptionsForProject (this: Project) = + let tryGetFSharpOptionsForProject (this: Project) : CancellableTask = if not this.IsFSharp then - raise (OperationCanceledException("Project is not a FSharp project.")) + CancellableTask.singleton ValueNone else match ProjectCache.Projects.TryGetValue(this) with - | true, result -> CancellableTask.singleton result + | true, result -> CancellableTask.singleton (ValueSome result) | _ -> cancellableTask { @@ -185,14 +197,32 @@ module private CheckerExtensions = let projectOptionsManager = service.FSharpProjectOptionsManager match! projectOptionsManager.TryGetOptionsByProject(this, ct) with - | ValueNone -> return raise (OperationCanceledException("FSharp project options not found.")) + | ValueNone -> return ValueNone | ValueSome(parsingOptions, projectOptions) -> let result = - (service.Checker, projectOptionsManager, parsingOptions, projectOptions) + { + Checker = service.Checker + OptionsManager = projectOptionsManager + ParsingOptions = parsingOptions + ProjectOptions = projectOptions + } - return ProjectCache.Projects.GetValue(this, ConditionalWeakTable<_, _>.CreateValueCallback(fun _ -> result)) + return + ValueSome(ProjectCache.Projects.GetValue(this, ConditionalWeakTable<_, _>.CreateValueCallback(fun _ -> result))) } + // The raising members predate the `Try` ones; they keep their tuple shape until every caller has + // moved over, so a missing result stays an OperationCanceledException with the same message there. + let getFSharpOptionsForProject (this: Project) = + if not this.IsFSharp then + raise (OperationCanceledException("Project is not a FSharp project.")) + else + cancellableTask { + match! tryGetFSharpOptionsForProject this with + | ValueSome options -> return options.Checker, options.OptionsManager, options.ParsingOptions, options.ProjectOptions + | ValueNone -> return raise (OperationCanceledException("FSharp project options not found.")) + } + let documentToSnapshot (document: Document) = cancellableTask { let! version = document.GetTextVersionAsync() @@ -212,17 +242,33 @@ module private CheckerExtensions = |> CancellableTask.whenAll |> CancellableTask.map (Seq.map (fun x -> x.ToString()) >> Set) - let getOnDiskReferences (options: FSharpProjectOptions) = - options.OtherOptions - |> Seq.filter (fun x -> x.StartsWith("-r:")) - |> Seq.map (fun x -> - let path = x.Substring(3) + let getOnDiskReferences (stamps: IReferenceStamps) (options: FSharpProjectOptions) = + [ + for option in options.OtherOptions do + if option.StartsWith("-r:", StringComparison.Ordinal) then + let path = option.Substring "-r:".Length - { - Path = path - LastModified = System.IO.File.GetLastWriteTimeUtc path - }) - |> Seq.toList + { + Path = path + LastModified = stamps.GetLastWriteTimeUtc path + } + ] + + // A snapshot's own ReferencesOnDisk come from FCS stat'ing the files, so a mismatch with the + // cached stamps means one side is behind; dropping the stamps costs one re-stat instead of a + // rebuild on every future Project instance after a missed notification. + let referencesOnDiskChanged (project: Project) (oldSnapshot: FSharpProjectSnapshot) options = + let stamps = + project.Solution.GetFSharpWorkspaceService().FSharpProjectOptionsManager.ReferenceStamps + + let current = getOnDiskReferences stamps options + let changed = current <> oldSnapshot.ProjectSnapshot.ReferencesOnDisk + + if changed then + for reference in current do + stamps.Invalidate reference.Path + + changed let createProjectSnapshot (snapshotAccumulatorOpt) (project: Project) (options: FSharpProjectOptions option) = cancellableTask { @@ -246,9 +292,7 @@ module private CheckerExtensions = System.Diagnostics.Trace.TraceWarning "Reference versions changed" None - | true, (true, (_, _, _, _, oldSnapshot: FSharpProjectSnapshot)) when - oldSnapshot.ProjectSnapshot.ReferencesOnDisk <> (getOnDiskReferences options) - -> + | true, (true, (_, _, _, _, oldSnapshot: FSharpProjectSnapshot)) when referencesOnDiskChanged project oldSnapshot options -> System.Diagnostics.Trace.TraceWarning "References on disk changed" None @@ -295,7 +339,6 @@ module private CheckerExtensions = | _ -> None let! newSnapshot = - match updatedSnapshot with | Some snapshot -> snapshot | _ -> @@ -510,15 +553,19 @@ module private CheckerExtensions = checker.ParseAndCheckDocumentWithPossibleStaleResults(document, options, allowStaleResults, userOpName = userOpName) } +let private orRaise message = + ValueOption.defaultWith (fun () -> raise (OperationCanceledException(message: string))) + type Document with - /// Get the FSharpParsingOptions and FSharpProjectOptions from the F# project that is associated with the given F# document. - member this.GetFSharpCompilationOptionsAsync(userOpName) = + /// Get the compilation options of the F# project that is associated with the given F# document, + /// or ValueNone while the project has none yet (still loading, reloading, a miscellaneous file). + member this.TryGetFSharpCompilationOptionsAsync(userOpName) : CancellableTask = if not this.Project.IsFSharp then - raise (OperationCanceledException("Document is not a FSharp document.")) + CancellableTask.singleton ValueNone else match ProjectCache.Projects.TryGetValue(this.Project) with - | true, result -> CancellableTask.singleton result + | true, result -> CancellableTask.singleton (ValueSome result) | _ -> cancellableTask { let service = this.Project.Solution.GetFSharpWorkspaceService() @@ -526,14 +573,36 @@ type Document with let! ct = CancellableTask.getCancellationToken () match! projectOptionsManager.TryGetOptionsForDocumentOrProject(this, ct, userOpName) with - | ValueNone -> return raise (OperationCanceledException("FSharp project options not found.")) + | ValueNone -> return ValueNone | ValueSome(parsingOptions, projectOptions) -> let result = - (service.Checker, projectOptionsManager, parsingOptions, projectOptions) + { + Checker = service.Checker + OptionsManager = projectOptionsManager + ParsingOptions = parsingOptions + ProjectOptions = projectOptions + } - return ProjectCache.Projects.GetValue(this.Project, ConditionalWeakTable<_, _>.CreateValueCallback(fun _ -> result)) + return + ValueSome( + ProjectCache.Projects.GetValue( + this.Project, + ConditionalWeakTable<_, _>.CreateValueCallback(fun _ -> result) + ) + ) } + /// Get the FSharpParsingOptions and FSharpProjectOptions from the F# project that is associated with the given F# document. + member this.GetFSharpCompilationOptionsAsync(userOpName) = + if not this.Project.IsFSharp then + raise (OperationCanceledException("Document is not a FSharp document.")) + else + cancellableTask { + match! this.TryGetFSharpCompilationOptionsAsync(userOpName) with + | ValueSome options -> return options.Checker, options.OptionsManager, options.ParsingOptions, options.ProjectOptions + | ValueNone -> return raise (OperationCanceledException("FSharp project options not found.")) + } + /// Get the compilation defines and language version from F# project that is associated with the given F# document. member this.GetFsharpParsingOptionsAsync(userOpName) = async { @@ -570,56 +639,99 @@ type Document with match this.GetFsharpParsingOptions() with | defines, _ -> defines + /// Parses the given F# document; ValueNone while its project has no compilation options. + member this.TryGetFSharpParseResultsAsync(userOpName) : CancellableTask = + cancellableTask { + match! this.TryGetFSharpCompilationOptionsAsync(userOpName) with + | ValueNone -> return ValueNone + | ValueSome options -> + let! parseResults = + if this.Project.UseTransparentCompiler then + options.Checker.ParseDocumentUsingTransparentCompiler(this, options.ProjectOptions, userOpName) + else + options.Checker.ParseDocument(this, options.ParsingOptions, userOpName) + + return ValueSome parseResults + } + /// Parses the given F# document. member this.GetFSharpParseResultsAsync(userOpName) = + this.TryGetFSharpParseResultsAsync(userOpName) + |> CancellableTask.map (orRaise "FSharp project options not found.") + + /// Parses the given F# document with the parsing options its project has already produced, or with defaults when + /// it has none yet: the only parse available while the project system is still loading. The defines can be the + /// wrong ones, so the tree describes a compilation that may never happen. + member this.GetFSharpQuickParseResultsAsync(userOpName) = + this.GetFSharpChecker().ParseDocument(this, this.GetFSharpQuickParsingOptions(), userOpName) + + /// Parses and checks the given F# document; ValueNone while its project has no compilation options + /// or the check was aborted. + member this.TryGetFSharpParseAndCheckResultsAsync + (userOpName) + : CancellableTask = cancellableTask { - let! checker, _, parsingOptions, options = this.GetFSharpCompilationOptionsAsync(userOpName) - - if this.Project.UseTransparentCompiler then - return! checker.ParseDocumentUsingTransparentCompiler(this, options, userOpName) - else - return! checker.ParseDocument(this, parsingOptions, userOpName) + match! this.TryGetFSharpCompilationOptionsAsync(userOpName) with + | ValueNone -> return ValueNone + | ValueSome options -> + match! options.Checker.ParseAndCheckDocument(this, options.ProjectOptions, userOpName, allowStaleResults = false) with + | Some(parseResults, checkResults) -> return ValueSome(struct (parseResults, checkResults)) + | None -> return ValueNone } /// Parses and checks the given F# document. member this.GetFSharpParseAndCheckResultsAsync(userOpName) = cancellableTask { - let! checker, _, _, projectOptions = this.GetFSharpCompilationOptionsAsync(userOpName) + match! this.TryGetFSharpParseAndCheckResultsAsync(userOpName) with + | ValueSome(struct (parseResults, checkResults)) -> return parseResults, checkResults + | ValueNone -> return raise (OperationCanceledException("Unable to get FSharp parse and check results.")) + } - match! checker.ParseAndCheckDocument(this, projectOptions, userOpName, allowStaleResults = false) with - | Some results -> return results - | _ -> return raise (OperationCanceledException("Unable to get FSharp parse and check results.")) + /// Get the semantic classifications of the given F# document; ValueNone while its project has no + /// compilation options or the background check produced none. + member this.TryGetFSharpSemanticClassificationAsync + (userOpName) + : CancellableTask = + cancellableTask { + match! this.TryGetFSharpCompilationOptionsAsync(userOpName) with + | ValueNone -> return ValueNone + | ValueSome options -> + let! result = + if this.Project.UseTransparentCompiler then + async { + let! projectSnapshot = getProjectSnapshotForDocument (this, options.ProjectOptions) + return! options.Checker.GetBackgroundSemanticClassificationForFile(this.FilePath, projectSnapshot) + } + else + options.Checker.GetBackgroundSemanticClassificationForFile(this.FilePath, options.ProjectOptions) + + return ValueOption.ofOption result } /// Get the semantic classifications of the given F# document. member this.GetFSharpSemanticClassificationAsync(userOpName) = cancellableTask { - let! checker, _, _, projectOptions = this.GetFSharpCompilationOptionsAsync(userOpName) - - let! result = - if this.Project.UseTransparentCompiler then - async { - let! projectSnapshot = getProjectSnapshotForDocument (this, projectOptions) - return! checker.GetBackgroundSemanticClassificationForFile(this.FilePath, projectSnapshot) - } - else - checker.GetBackgroundSemanticClassificationForFile(this.FilePath, projectOptions) - - return - result - |> Option.defaultWith (fun _ -> raise (OperationCanceledException("Unable to get FSharp semantic classification."))) + match! this.TryGetFSharpSemanticClassificationAsync(userOpName) with + | ValueSome classification -> return classification + | ValueNone -> return raise (OperationCanceledException("Unable to get FSharp semantic classification.")) } - /// Find F# references in the given F# document. - member inline this.FindFSharpReferencesAsync(symbol, projectSnapshot: FSharpProjectSnapshot, [] onFound, userOpName) = + /// Find F# references in the given F# document, through the project snapshot when the transparent + /// compiler is in use. + member inline this.FindFSharpReferencesAsync + ( + symbol, + projectSnapshot: FSharpProjectSnapshot voption, + [] onFound: Text.range seq -> CancellableTask, + userOpName + ) = cancellableTask { let! checker, _, _, projectOptions = this.GetFSharpCompilationOptionsAsync(userOpName) let! symbolUses = - - if this.Project.UseTransparentCompiler then - checker.FindBackgroundReferencesInFile(this.FilePath, projectSnapshot, symbol) - else + match projectSnapshot with + | ValueSome projectSnapshot -> checker.FindBackgroundReferencesInFile(this.FilePath, projectSnapshot, symbol) + | ValueNone -> checker.FindBackgroundReferencesInFile( this.FilePath, projectOptions, @@ -628,11 +740,7 @@ type Document with fastCheck = this.Project.IsFastFindReferencesEnabled ) - do! - symbolUses - |> Seq.map onFound - |> CancellableTask.whenAll - |> CancellableTask.ignore + do! onFound symbolUses } /// Try to find a F# lexer/token symbol of the given F# document and position. @@ -657,24 +765,66 @@ type Document with ) } +let rec private definesTestedBy expr = + seq { + match expr with + | IfDirectiveExpression.And(left, right) + | IfDirectiveExpression.Or(left, right) -> + yield! definesTestedBy left + yield! definesTestedBy right + | IfDirectiveExpression.Not expr -> yield! definesTestedBy expr + | IfDirectiveExpression.Ident name -> yield name + } + +/// Whether the file can parse differently under the given defines: it tests one of them in a +/// conditional directive. A file whose `#if` only tests defines the two instances share compiles to +/// the same tree in both, however many directives it has. +let private dependsOnDefines (defines: string Set) (parseTree: ParsedInput) = + let directives = + match parseTree with + | ParsedInput.ImplFile file -> file.Trivia.ConditionalDirectives + | ParsedInput.SigFile file -> file.Trivia.ConditionalDirectives + + directives + |> List.exists (function + | ConditionalDirectiveTrivia.If(expr, _) + | ConditionalDirectiveTrivia.Elif(expr, _) -> definesTestedBy expr |> Seq.exists defines.Contains + | ConditionalDirectiveTrivia.Else _ + | ConditionalDirectiveTrivia.EndIf _ -> false) + +/// How many documents of one project a search keeps in flight. The throttle it shares with the other +/// projects decides how many of those actually run. +[] +let private WorkersPerProject = 4 + type Project with - /// Find F# references in the given project. - member this.FindFSharpReferencesAsync(symbol: FSharpSymbol, projectSnapshot, onFound, userOpName) = + /// Find F# references in the given project. When `searchedInstance` is another target-framework + /// instance of the same project file that has already been searched, only the documents whose + /// sources can differ from it are searched: files compiled only here and files whose conditional + /// compilation tests a define the two instances disagree on. + member this.FindFSharpReferencesAsync + ( + symbol: FSharpSymbol, + projectSnapshot: FSharpProjectSnapshot voption, + searchedInstance: Project voption, + throttle: SemaphoreSlim, + onFound, + userOpName + ) = cancellableTask { - - let declarationLocation = - symbol.SignatureLocation - |> Option.map Some - |> Option.defaultValue symbol.DeclarationLocation - let declarationDocument = - declarationLocation |> Option.bind this.Solution.TryGetDocumentFromFSharpRange + symbol.SignatureLocation + |> Option.orElse symbol.DeclarationLocation + |> Option.bind (fun range -> + this.Solution.GetDocumentIdsWithFilePath(Path.GetFullPathSafe range.FileName) + |> Seq.tryFind (fun id -> id.ProjectId = this.Id) + |> Option.map this.GetDocument) - // Can we skip documents, which are above current, since they can't contain symbols from current one. + // Documents before the declaration in compile order cannot refer to it. let! canSkipDocuments = match declarationDocument with - | Some document when this.IsFastFindReferencesEnabled && document.Project = this -> + | Some document when this.IsFastFindReferencesEnabled -> cancellableTask { let! _, _, _, options = document.GetFSharpCompilationOptionsAsync(userOpName) @@ -685,7 +835,6 @@ type Project with null return - options.SourceFiles |> Seq.takeWhile ((<>) document.FilePath) |> Seq.filter ((<>) signatureFile) @@ -693,21 +842,58 @@ type Project with } | _ -> CancellableTask.singleton Set.empty - let documents = + let searchedPaths = + searchedInstance + |> ValueOption.map (fun instance -> HashSet(instance.Documents |> Seq.map _.FilePath, StringComparer.OrdinalIgnoreCase)) + + // Only the defines one instance has and the other lacks can make a shared file parse + // differently; the ones they agree on cannot, however many directives test them. + let definesOf (project: Project) = + getFSharpOptionsForProject project + |> CancellableTask.map (fun (_, _, parsingOptions: FSharpParsingOptions, _) -> Set parsingOptions.ConditionalDefines) + + let! differingDefines = + match searchedInstance with + | ValueNone -> CancellableTask.singleton Set.empty + | ValueSome instance -> + cancellableTask { + let! defines = definesOf this + let! searchedDefines = definesOf instance + return (defines - searchedDefines) + (searchedDefines - defines) + } + + let needsSearch (document: Document) = + match searchedPaths with + | ValueSome paths when paths.Contains document.FilePath -> + if differingDefines.IsEmpty then + CancellableTask.singleton false + else + document.GetFSharpParseResultsAsync userOpName + |> CancellableTask.map (fun parseResults -> dependsOnDefines differingDefines parseResults.ParseTree) + | _ -> CancellableTask.singleton true + + let search (document: Document) = + cancellableTask { + let! ct = CancellableTask.getCancellationToken () + do! throttle.WaitAsync ct + + try + let! needed = needsSearch document + + if needed then + do! document.FindFSharpReferencesAsync(symbol, projectSnapshot, onFound document, userOpName) + finally + throttle.Release() |> ignore + } + + do! this.Documents - |> Seq.filter (fun document -> isFSharpSourceFile document.FilePath) - |> Seq.filter (fun document -> not (canSkipDocuments.Contains document.FilePath)) - - if this.IsFastFindReferencesEnabled then - do! - documents - |> Seq.map (fun doc -> - doc.FindFSharpReferencesAsync(symbol, projectSnapshot, (fun range -> onFound doc range), userOpName)) - // Throttle to avoid launching a typecheck per document in the project all at once. - |> CancellableTask.whenAllThrottled (max 1 Environment.ProcessorCount) - else - for doc in documents do - do! doc.FindFSharpReferencesAsync(symbol, projectSnapshot, (onFound doc), userOpName) + |> Seq.filter (fun document -> + isFSharpSourceFile document.FilePath + && not (canSkipDocuments.Contains document.FilePath)) + // Workers take the next document when they free up. Starting one task per document + // instead would leave every document of the solution parked on the throttle at once. + |> CancellableTask.forEachThrottled WorkersPerProject search } member this.GetFSharpCompilationOptionsAsync() = this |> getFSharpOptionsForProject diff --git a/vsintegration/src/FSharp.Editor/Navigation/CrossLanguageSymbolNavigation.fs b/vsintegration/src/FSharp.Editor/Navigation/CrossLanguageSymbolNavigation.fs new file mode 100644 index 00000000000..96c95055874 --- /dev/null +++ b/vsintegration/src/FSharp.Editor/Navigation/CrossLanguageSymbolNavigation.fs @@ -0,0 +1,428 @@ +// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information. + +namespace Microsoft.VisualStudio.FSharp.Editor + +open System +open System.Collections.Generic +open System.Composition +open System.Threading +open System.Threading.Tasks + +open Microsoft.CodeAnalysis +open Microsoft.CodeAnalysis.ExternalAccess.FSharp.Navigation +open Microsoft.VisualStudio.LanguageServices + +open FSharp.Compiler.EditorServices +open FSharp.Compiler.Symbols +open FSharp.Compiler.Text +open CancellableTasks + +[] +type internal SymbolMemberType = + | Event + | Property + | Method + | Constructor + | Other + +type internal SymbolPath = + { + EntityPath: string list + MemberOrValName: string + GenericParameters: int + } + +[] +type internal DocCommentId = + | Member of SymbolPath * SymbolMemberType: SymbolMemberType + | Field of SymbolPath + | Type of EntityPath: string list + | None + +type FSharpNavigableLocation(metadataAsSource: FSharpMetadataAsSourceService, symbolRange: range, project: Project) = + interface IFSharpNavigableLocation with + member _.NavigateToAsync(_options: FSharpNavigationOptions2, cancellationToken: CancellationToken) : Task = + cancellableTask { + let targetPath = symbolRange.FileName + + let! cancellationToken = CancellableTask.getCancellationToken () + + let targetDoc = + project.Solution.TryGetDocumentFromFSharpRange(symbolRange, project.Id) + + match targetDoc with + | None -> return false + | Some targetDoc -> + let! targetSource = targetDoc.GetTextAsync(cancellationToken) + let gtd = GoToDefinition(metadataAsSource) + + let (|Signature|Implementation|) filepath = + if isSignatureFile filepath then + Signature + else + Implementation + + match targetPath with + | Signature -> return! gtd.NavigateToSymbolDefinitionAsync(targetDoc, targetSource, symbolRange) + | Implementation -> return! gtd.NavigateToSymbolDeclarationAsync(targetDoc, targetSource, symbolRange) + } + |> CancellableTask.start cancellationToken + +/// Locates the F# declaration Roslyn names by assembly and documentation comment id when C# or +/// Visual Basic navigates into an F# project. Kept apart from the MEF service so it can be exercised +/// without a Visual Studio workspace. +module internal CrossLanguageSymbolNavigation = + + [] + let private UserOpName = "CrossLanguageSymbolNavigation" + + let docCommentIdToPath (docId: string) = + match XmlDocSigParser.parseDocCommentId docId with + | ParsedDocCommentId.Type path -> DocCommentId.Type path + + | ParsedDocCommentId.Member(typePath, memberName, genericArity, kind) -> + // The parser reports constructors as .ctor; the F# lookup needs the backticked form. + let memberOrValName = if memberName = ".ctor" then "``.ctor``" else memberName + + let symbolMemberType = + match kind with + | DocCommentIdKind.Method -> + if memberName = ".ctor" then + SymbolMemberType.Constructor + else + SymbolMemberType.Method + | DocCommentIdKind.Property -> SymbolMemberType.Property + | DocCommentIdKind.Event -> SymbolMemberType.Event + | _ -> SymbolMemberType.Other + + DocCommentId.Member( + { + EntityPath = typePath + MemberOrValName = memberOrValName + GenericParameters = genericArity + }, + symbolMemberType + ) + + | ParsedDocCommentId.Field(typePath, fieldName) -> + DocCommentId.Field + { + EntityPath = typePath + MemberOrValName = fieldName + GenericParameters = 0 + } + + | ParsedDocCommentId.None -> DocCommentId.None + + /// The fields of the entity, and the literals of a module, which compile to fields. + let private tryFindFieldByName (name: string) (e: FSharpEntity) = + let fields = + e.FSharpFields + |> Seq.filter (fun x -> x.DisplayName = name && not x.IsCompilerGenerated) + |> Seq.map _.DeclarationLocation + + let literals = + e.TryGetMembersFunctionsAndValues() + |> Seq.filter (fun v -> v.LiteralValue.IsSome && (v.CompiledName = name || v.DisplayName = name)) + |> Seq.map _.DeclarationLocation + + if + Seq.isEmpty fields + && Seq.isEmpty literals + && (e.IsFSharpUnion || e.IsFSharpRecord) + then + Seq.singleton e.DeclarationLocation + else + Seq.append fields literals + + let private tryFindValByNameAndType + (name: string) + (symbolMemberType: SymbolMemberType) + (genericParametersCount: int) + (e: FSharpEntity) + (entities: FSharpMemberOrFunctionOrValue seq) + = + + let defaultFilter (e: FSharpMemberOrFunctionOrValue) = + (e.DisplayName = name || e.CompiledName = name) + && e.GenericParameters.Count = genericParametersCount + + let isProperty (e: FSharpMemberOrFunctionOrValue) = defaultFilter e && e.IsProperty + let isConstructor (e: FSharpMemberOrFunctionOrValue) = defaultFilter e && e.IsConstructor + + let getLocation (e: FSharpMemberOrFunctionOrValue) = e.DeclarationLocation + + let filteredEntities: range seq = + match symbolMemberType with + | SymbolMemberType.Other + | SymbolMemberType.Method -> entities |> Seq.filter defaultFilter |> Seq.map getLocation + // F# record-specific logic, if navigating to the record's ctor, then navigate to record declaration. + // If we navigating to F# record property, we first check if it's "custom" property, if it's one of the record fields, we search for it in the fields. + | SymbolMemberType.Constructor when e.IsFSharpRecord -> Seq.singleton e.DeclarationLocation + | SymbolMemberType.Property when e.IsFSharpRecord -> + let properties = entities |> Seq.filter isProperty |> Seq.map getLocation + let fields = tryFindFieldByName name e + Seq.append properties fields + | SymbolMemberType.Constructor -> entities |> Seq.filter isConstructor |> Seq.map getLocation + // When navigating to property for the record, it will be in members bag for custom ones, but will be in the fields in fields. + | SymbolMemberType.Event // Events are just properties` + | SymbolMemberType.Property -> entities |> Seq.filter isProperty |> Seq.map getLocation + + filteredEntities + + /// The union case behind its compiled members: the `NewCase` factory, the `IsCase` tester and + /// the `Case` property of a nullary case. + let private unionCaseLocations (name: string) (entity: FSharpEntity) = + if entity.IsFSharpUnion then + entity.UnionCases + |> Seq.filter (fun unionCase -> + let compiled = unionCase.CompiledName + name = compiled || name = $"New{compiled}" || name = $"Is{compiled}") + |> Seq.map _.DeclarationLocation + else + Seq.empty + + /// The members of the entity the id names: those whose compiled id matches exactly and, when + /// `byShape`, those whose name, kind and arity fit when no id matched. + let private memberLocations + (byShape: bool) + (documentationCommentId: string) + (symbolPath: SymbolPath) + (memberType: SymbolMemberType) + (entity: FSharpEntity) + = + let members = entity.TryGetMembersFunctionsAndValues() + + let exact = + seq { + yield! + members + |> Seq.filter (fun m -> m.XmlDocSig = documentationCommentId) + |> Seq.map _.DeclarationLocation + + yield! unionCaseLocations symbolPath.MemberOrValName entity + } + + if byShape && Seq.isEmpty exact then + tryFindValByNameAndType symbolPath.MemberOrValName memberType symbolPath.GenericParameters entity members + else + exact + + let private declarationsIn (byShape: bool) (signature: FSharpAssemblySignature) (documentationCommentId: string) (path: DocCommentId) = + let inEntity entityPath (locationsOf: FSharpEntity -> range seq) = + signature.FindEntityByPath entityPath + |> Option.map locationsOf + |> Option.defaultValue Seq.empty + + match path with + | DocCommentId.Member(symbolPath, memberType) -> + inEntity symbolPath.EntityPath (memberLocations byShape documentationCommentId symbolPath memberType) + | DocCommentId.Field symbolPath -> inEntity symbolPath.EntityPath (tryFindFieldByName symbolPath.MemberOrValName) + | DocCommentId.Type entityPath -> inEntity entityPath (fun entity -> Seq.singleton entity.DeclarationLocation) + | DocCommentId.None -> Seq.empty + + let private entityPathOf (path: DocCommentId) = + match path with + | DocCommentId.Member(symbolPath, _) + | DocCommentId.Field symbolPath -> symbolPath.EntityPath + | DocCommentId.Type entityPath -> entityPath + | DocCommentId.None -> [] + + /// A compiled segment of a doc id against a source segment: the generic arity suffix and the + /// `Module` suffix of `CompilationRepresentation(ModuleSuffix)` exist only in compiled names. + let private segmentMatches (compiled: ReadOnlySpan) (source: ReadOnlySpan) = + let compiled = + match compiled.IndexOf '`' with + | -1 -> compiled + | arity -> compiled.Slice(0, arity) + + compiled.Equals(source, StringComparison.Ordinal) + || (compiled.Length = source.Length + "Module".Length + && compiled.Slice(0, source.Length).Equals(source, StringComparison.Ordinal) + && compiled.Slice(source.Length).Equals("Module".AsSpan(), StringComparison.Ordinal)) + + let rec private pathMatches (entityPath: string list) (source: ReadOnlySpan) = + match entityPath with + | [] -> source.IsEmpty + | [ last ] -> source.IndexOf '.' = -1 && segmentMatches (last.AsSpan()) source + | segment :: rest -> + match source.IndexOf '.' with + | -1 -> false + | dot -> + segmentMatches (segment.AsSpan()) (source.Slice(0, dot)) + && pathMatches rest (source.Slice(dot + 1)) + + /// Whether the parsed item declares the entity the doc id names. + let declaresEntity (entityPath: string list) (item: NavigableItem) = + match item.Kind with + | NavigableItemKind.Module + | NavigableItemKind.Type + | NavigableItemKind.Exception -> + match item.Container.FullName with + | "" -> pathMatches entityPath (item.Name.AsSpan()) + | container -> pathMatches entityPath ($"{container}.{item.Name}".AsSpan()) + | _ -> false + + /// The project's documents whose parse tree declares the entity, in compile order. + let candidateDocuments (itemsCache: FSharpNavigableItemsCache) (entityPath: string list) (project: Project) = + cancellableTask { + let! ct = CancellableTask.getCancellationToken () + let! _, _, _, options = project.GetFSharpCompilationOptionsAsync() + + let compileOrder = Dictionary(StringComparer.OrdinalIgnoreCase) + + options.SourceFiles + |> Array.iteri (fun index path -> compileOrder[path] <- index) + + let declaresIn (document: Document) = + cancellableTask { + ct.ThrowIfCancellationRequested() + let! items = itemsCache.GetNavigableItems document + + if items |> Array.exists (declaresEntity entityPath) then + return ValueSome document + else + return ValueNone + } + + let! candidates = + project.Documents + |> Seq.filter (fun document -> isFSharpSourceFile document.FilePath) + |> Seq.map declaresIn + // Throttle to avoid launching a parse per document in the project all at once. + |> CancellableTask.whenAllThrottled (max 1 Environment.ProcessorCount) + + return + candidates + |> Array.chooseV id + |> Array.sortBy (fun document -> + match compileOrder.TryGetValue document.FilePath with + | true, index -> index + | _ -> Int32.MaxValue) + |> List.ofArray + } + + let private tryLocateInDocument (byShape: bool) (documentationCommentId: string) (path: DocCommentId) (document: Document) = + cancellableTask { + match! document.TryGetFSharpParseAndCheckResultsAsync UserOpName with + | ValueNone -> return ValueNone + | ValueSome(struct (_, checkResults)) -> + return + declarationsIn byShape checkResults.PartialAssemblySignature documentationCommentId path + |> Seq.tryHeadV + } + + /// Checks only the documents that declare the entity. An exact id match in any of them wins; + /// the name-and-shape heuristics run only on the last one, whose partial signature holds every + /// member the entity gets from the files that declare it. + let tryLocateViaNavigableItems + (itemsCache: FSharpNavigableItemsCache) + (documentationCommentId: string) + (path: DocCommentId) + (project: Project) + = + cancellableTask { + let! candidates = candidateDocuments itemsCache (entityPathOf path) project + + match! + candidates + |> CancellableTask.tryPick (tryLocateInDocument false documentationCommentId path) + with + | ValueSome range -> return ValueSome range + | ValueNone -> + match List.tryLast candidates with + | Some last -> return! tryLocateInDocument true documentationCommentId path last + | None -> return ValueNone + } + + /// Checks the whole project. + let tryLocateInProject (documentationCommentId: string) (path: DocCommentId) (project: Project) = + cancellableTask { + let! checker, _, _, options = project.GetFSharpCompilationOptionsAsync() + let! result = checker.ParseAndCheckProject(options) + + return + declarationsIn true result.AssemblySignature documentationCommentId path + |> Seq.tryHeadV + } + + /// The declaration's range and the project holding it, for the assembly name and doc id Roslyn passes. + let tryFindDeclaration + (itemsCache: FSharpNavigableItemsCache) + (solution: Solution) + (assemblyName: string) + (documentationCommentId: string) + = + match docCommentIdToPath documentationCommentId with + | DocCommentId.None -> CancellableTask.singleton ValueNone + | path -> + cancellableTask { + // The target frameworks of one project declare the same entities in the same files apart + // from conditional compilation, so one instance per project file goes first. + let instances = + solution.Projects + |> Seq.filter (fun p -> p.IsFSharp && p.AssemblyName = assemblyName) + |> Seq.groupBy _.FilePath + |> Seq.map (snd >> List.ofSeq) + |> Seq.toList + + let ordered = + [ + for instance in instances -> instance.Head + for instance in instances do + yield! instance.Tail + ] + + let located (locate: Project -> CancellableTask) (project: Project) = + locate project + |> CancellableTask.map (ValueOption.map (fun range -> struct (range, project))) + + match! + ordered + |> CancellableTask.tryPick (located (tryLocateViaNavigableItems itemsCache documentationCommentId path)) + with + | ValueSome found -> return ValueSome found + | ValueNone -> + // Checking a whole project is the expensive answer, and the target frameworks of one + // project check the same declarations: whatever only one of them compiles is under + // conditional compilation, which the parsed declarations above already covered. + return! + [ for instance in instances -> instance.Head ] + |> CancellableTask.tryPick (located (tryLocateInProject documentationCommentId path)) + } + +[)>] +[)>] +type internal FSharpCrossLanguageSymbolNavigationService + [] + ( + itemsCache: FSharpNavigableItemsCache, + metadataAsSource: FSharpMetadataAsSourceService, + [] workspace: VisualStudioWorkspace | null + ) = + + static member internal DocCommentIdToPath(docId: string) = + CrossLanguageSymbolNavigation.docCommentIdToPath docId + + interface IFSharpCrossLanguageSymbolNavigationService with + member _.TryGetNavigableLocationAsync + (assemblyName: string, documentationCommentId: string, cancellationToken: CancellationToken) + : Task = + cancellableTask { + match workspace with + | null -> return null + | workspace -> + match! + CrossLanguageSymbolNavigation.tryFindDeclaration + itemsCache + workspace.CurrentSolution + assemblyName + documentationCommentId + with + | ValueSome(struct (range, project)) -> + return FSharpNavigableLocation(metadataAsSource, range, project) :> IFSharpNavigableLocation + | ValueNone -> + // Roslyn falls back to its own metadata-as-source when no location comes back. + return null + } + |> CancellableTask.start cancellationToken diff --git a/vsintegration/src/FSharp.Editor/Navigation/FindUsagesService.fs b/vsintegration/src/FSharp.Editor/Navigation/FindUsagesService.fs index c313d4f27ee..0abb251e508 100644 --- a/vsintegration/src/FSharp.Editor/Navigation/FindUsagesService.fs +++ b/vsintegration/src/FSharp.Editor/Navigation/FindUsagesService.fs @@ -2,6 +2,8 @@ namespace Microsoft.VisualStudio.FSharp.Editor +open System +open System.Collections.Generic open System.Collections.Immutable open System.Composition open System.Threading.Tasks @@ -10,6 +12,8 @@ open Microsoft.CodeAnalysis open Microsoft.CodeAnalysis.ExternalAccess.FSharp open Microsoft.CodeAnalysis.ExternalAccess.FSharp.FindUsages open Microsoft.CodeAnalysis.ExternalAccess.FSharp.Editor.FindUsages +open Microsoft.CodeAnalysis.FindSymbols +open Microsoft.CodeAnalysis.Text open FSharp.Compiler.EditorServices open FSharp.Compiler.Text @@ -17,8 +21,9 @@ open CancellableTasks module FSharpFindUsagesService = + /// Reports the uses found in one document: its text is read once for all of them, and Roslyn takes + /// them one at a time anyway. let onSymbolFound - allReferences declarationRange externalDefinitionItem definitionItems @@ -26,51 +31,50 @@ module FSharpFindUsagesService = symbolName (onReferenceFoundAsync: FSharpSourceReferenceItem -> Task) (doc: Document) - (symbolUse: range) + (symbolUses: range seq) = cancellableTask { let! cancellationToken = CancellableTask.getCancellationToken () let! sourceText = doc.GetTextAsync(cancellationToken) - match declarationRange, RoslynHelpers.TryFSharpRangeToTextSpan(sourceText, symbolUse) with - | Some declRange, _ when Range.equals declRange symbolUse -> () - | _, ValueNone -> () - | _, ValueSome _ when not allReferences -> () - | _, ValueSome textSpan -> - match textSpan with - | Tokenizer.FixedSpan sourceText symbolName fixedSpan -> - let definitionItem = - if isExternal then - externalDefinitionItem - else - definitionItems - |> Array.tryFind (snd >> (=) doc.Project.FilePath) - |> Option.map (fun (definitionItem, _) -> definitionItem) - |> Option.defaultValue externalDefinitionItem - - let referenceItem = - FSharpSourceReferenceItem(definitionItem, FSharpDocumentSpan(doc, fixedSpan)) - // REVIEW: OnReferenceFoundAsync is throwing inside Roslyn, putting a try/with so find-all refs doesn't fail. - try - do! onReferenceFoundAsync referenceItem - with _ -> - () - | _ -> () + let definitionItem = + if isExternal then + externalDefinitionItem + else + definitionItems + |> Array.tryFindV (fun struct (_, project: Project) -> project.FilePath = doc.Project.FilePath) + |> ValueOption.map (fun struct (definitionItem, _) -> definitionItem) + |> ValueOption.defaultValue externalDefinitionItem + + for symbolUse in symbolUses do + cancellationToken.ThrowIfCancellationRequested() + + match declarationRange, RoslynHelpers.TryFSharpRangeToTextSpan(sourceText, symbolUse) with + | ValueSome declRange, _ when Range.equals declRange symbolUse -> () + | _, ValueNone -> () + | _, ValueSome textSpan -> + match textSpan with + | Tokenizer.FixedSpan sourceText symbolName fixedSpan -> + let referenceItem = + FSharpSourceReferenceItem(definitionItem, FSharpDocumentSpan(doc, fixedSpan)) + // REVIEW: OnReferenceFoundAsync is throwing inside Roslyn, putting a try/with so find-all refs doesn't fail. + try + do! onReferenceFoundAsync referenceItem + with error when not (error :? OperationCanceledException) -> + () + | _ -> () } // File can be included in more than one project, hence single `range` may results with multiple `Document`s. - let rangeToDocumentSpans (solution: Solution, range: range, symbolName: string) = + let rangeToDocumentSpans (document: Document, range: range, symbolName: string) = if range.Start = range.End then CancellableTask.singleton [||] else cancellableTask { - let documentIds = solution.GetDocumentIdsWithFilePath(range.FileName) - let! spans = seq { - for documentId in documentIds do + for doc in document.GetSolutionDocumentsWithFilePath range.FileName do cancellableTask { - let doc = solution.GetDocument(documentId) let! cancellationToken = CancellableTask.getCancellationToken () let! sourceText = doc.GetTextAsync(cancellationToken) @@ -84,6 +88,73 @@ module FSharpFindUsagesService = return spans |> Array.choose id } + let private referencingCompilationProjects (declaringProject: Project) = + match declaringProject.OutputFilePath with + | null -> [] + | outputFilePath -> + ProjectFiltering.getProjectsReferencingAssembly outputFilePath declaringProject.Solution + |> List.filter (fun project -> not project.IsFSharp && project.SupportsCompilation) + + /// Locations in a C# or VB project of the symbol with the given documentation comment id. + let private findRoslynReferences (docId: string) (project: Project) = + cancellableTask { + let! cancellationToken = CancellableTask.getCancellationToken () + // Building a compilation and walking it costs the same cores the F# search is using. + do! SymbolHelpers.searchThrottle.WaitAsync cancellationToken + + try + match! project.GetCompilationAsync cancellationToken with + | null -> return Seq.empty + | compilation -> + match DocumentationCommentId.GetFirstSymbolForDeclarationId(docId, compilation) with + | null -> return Seq.empty + | symbol -> + let! referencedSymbols = + SymbolFinder.FindReferencesAsync( + symbol, + project.Solution, + ImmutableHashSet.CreateRange project.Documents, + cancellationToken + ) + + return referencedSymbols |> Seq.collect _.Locations + finally + SymbolHelpers.searchThrottle.Release() |> ignore + } + + // Every search may build a compilation, and those cost memory, not just a core. + [] + let private ConcurrentCompilations = 4 + + /// The uses in the C# and VB projects that reference the assembly of a project declaring the symbol, + /// each with the definition item to report them under. + let private findCrossLanguageReferences (docId: string) (definitionItems: struct (FSharpDefinitionItem * Project) seq) = + seq { + for struct (definitionItem, declaringProject) in definitionItems do + for project in referencingCompilationProjects declaringProject -> struct (definitionItem, project) + } + |> Seq.distinctBy (fun struct (_, project) -> project.Id) + |> Seq.map (fun struct (definitionItem, project) -> + findRoslynReferences docId project + |> CancellableTask.map (Seq.map (fun location -> struct (definitionItem, location)))) + |> CancellableTask.whenAllThrottled ConcurrentCompilations + |> CancellableTask.map Seq.concat + + /// Reports each file span once: the target-framework instances of a consumer share their files. + let private reportCrossLanguageReferences + (found: struct (FSharpDefinitionItem * ReferenceLocation) seq) + (onReferenceFoundAsync: FSharpSourceReferenceItem -> Task) + = + cancellableTask { + let reported = HashSet() + + for struct (definitionItem, location) in found do + let span = location.Location.SourceSpan + + if reported.Add(struct (location.Document.FilePath, span)) then + do! onReferenceFoundAsync (FSharpSourceReferenceItem(definitionItem, FSharpDocumentSpan(location.Document, span))) + } + let findReferencedSymbolsAsync (document: Document, position: int, context: IFSharpFindUsagesContext, allReferences: bool, userOp: string) : CancellableTask = @@ -114,13 +185,13 @@ module FSharpFindUsagesService = let declarationRange = match declaration with - | FindDeclResult.DeclFound range -> Some range - | _ -> None + | FindDeclResult.DeclFound range -> ValueSome range + | _ -> ValueNone let! declarationSpans = match declarationRange with - | Some range -> rangeToDocumentSpans (document.Project.Solution, range, symbol.Ident.idText) - | None -> CancellableTask.singleton [||] + | ValueSome range -> rangeToDocumentSpans (document, range, symbol.Ident.idText) + | ValueNone -> CancellableTask.singleton [||] let declarationSpans = declarationSpans @@ -139,27 +210,38 @@ module FSharpFindUsagesService = let definitionItems = declarationSpans - |> Array.map (fun span -> FSharpDefinitionItem.Create(tags, displayParts, span), span.Document.Project.FilePath) + |> Array.map (fun span -> struct (FSharpDefinitionItem.Create(tags, displayParts, span), span.Document.Project)) do! definitionItems - |> Seq.map (fst >> context.OnDefinitionFoundAsync) + |> Seq.map (fun struct (definitionItem, _) -> context.OnDefinitionFoundAsync(definitionItem)) |> Task.WhenAll if isExternal then do! context.OnDefinitionFoundAsync(externalDefinitionItem) - let onFound = - onSymbolFound - allReferences - declarationRange - externalDefinitionItem - definitionItems - isExternal - symbol.Ident.idText - context.OnReferenceFoundAsync - - do! SymbolHelpers.findSymbolUses symbolUse document checkFileResults onFound + // Find Implementations wants the definitions alone: reporting a use is what + // `allReferences` gates, so searching for them would throw the whole search away. + if allReferences then + let onFound = + onSymbolFound + declarationRange + externalDefinitionItem + definitionItems + isExternal + symbol.Ident.idText + context.OnReferenceFoundAsync + + // Searched alongside the F# projects, reported after them. + let crossLanguageSearch = + match symbolUse.Symbol.DocumentationCommentId with + | ValueSome docId when not isExternal && not symbolUse.Symbol.IsInternalToProject -> + findCrossLanguageReferences docId definitionItems cancellationToken + | _ -> Task.FromResult Seq.empty + + do! SymbolHelpers.findSymbolUses symbolUse document checkFileResults onFound + let! found = crossLanguageSearch + do! reportCrossLanguageReferences found context.OnReferenceFoundAsync } open FSharpFindUsagesService diff --git a/vsintegration/src/FSharp.Editor/Navigation/GoToDefinition.fs b/vsintegration/src/FSharp.Editor/Navigation/GoToDefinition.fs index 6bc86ae57a3..5e799d74552 100644 --- a/vsintegration/src/FSharp.Editor/Navigation/GoToDefinition.fs +++ b/vsintegration/src/FSharp.Editor/Navigation/GoToDefinition.fs @@ -44,34 +44,39 @@ module private Symbol = inner [] root |> String.concat "." module private FindDeclExternalType = - let rec tryOfRoslynType (typesym: ITypeSymbol) : FindDeclExternalType option = + let rec tryOfRoslynType (typesym: ITypeSymbol) : FindDeclExternalType voption = match typesym with | :? IPointerTypeSymbol as ptrparam -> tryOfRoslynType ptrparam.PointedAtType - |> Option.map FindDeclExternalType.Pointer - | :? IArrayTypeSymbol as arrparam -> tryOfRoslynType arrparam.ElementType |> Option.map FindDeclExternalType.Array - | :? ITypeParameterSymbol as typaram -> Some(FindDeclExternalType.TypeVar typaram.Name) + |> ValueOption.map FindDeclExternalType.Pointer + | :? IArrayTypeSymbol as arrparam -> + tryOfRoslynType arrparam.ElementType + |> ValueOption.map FindDeclExternalType.Array + | :? ITypeParameterSymbol as typaram -> ValueSome(FindDeclExternalType.TypeVar typaram.Name) | :? INamedTypeSymbol as namedTypeSym -> namedTypeSym.TypeArguments |> Seq.map tryOfRoslynType |> List.ofSeq - |> Option.ofOptionList - |> Option.map (fun genericArgs -> FindDeclExternalType.Type(Symbol.fullName typesym, genericArgs)) + |> ValueOption.ofValueOptionList + |> ValueOption.map (fun genericArgs -> FindDeclExternalType.Type(Symbol.fullName typesym, genericArgs)) | _ -> Debug.Assert(false, sprintf "GoToDefinitionService: Unexpected Roslyn type symbol subclass: %O" (typesym.GetType())) - None + ValueNone module private FindDeclExternalParam = - let tryOfRoslynParameter (param: IParameterSymbol) : FindDeclExternalParam option = + let tryOfRoslynParameter (param: IParameterSymbol) : FindDeclExternalParam voption = FindDeclExternalType.tryOfRoslynType param.Type - |> Option.map (fun ty -> FindDeclExternalParam.Create(ty, param.RefKind <> RefKind.None)) + |> ValueOption.map (fun ty -> FindDeclExternalParam.Create(ty, param.RefKind <> RefKind.None)) - let tryOfRoslynParameters (paramSyms: ImmutableArray) : FindDeclExternalParam list option = - paramSyms |> Seq.map tryOfRoslynParameter |> Seq.toList |> Option.ofOptionList + let tryOfRoslynParameters (paramSyms: ImmutableArray) : FindDeclExternalParam list voption = + paramSyms + |> Seq.map tryOfRoslynParameter + |> Seq.toList + |> ValueOption.ofValueOptionList module private ExternalSymbol = - let rec ofRoslynSymbol (symbol: ISymbol) : (ISymbol * FindDeclExternalSymbol) list = + let rec ofRoslynSymbol (symbol: ISymbol) : struct (ISymbol * FindDeclExternalSymbol) list = let container = Symbol.fullName symbol.ContainingSymbol match symbol with @@ -80,18 +85,18 @@ module private ExternalSymbol = let constructors = typesym.InstanceConstructors - |> Seq.choose<_, ISymbol * FindDeclExternalSymbol> (fun methsym -> + |> Seq.chooseV<_, struct (ISymbol * FindDeclExternalSymbol)> (fun methsym -> FindDeclExternalParam.tryOfRoslynParameters methsym.Parameters - |> Option.map (fun args -> upcast methsym, FindDeclExternalSymbol.Constructor(fullTypeName, args))) + |> ValueOption.map (fun args -> struct (methsym, FindDeclExternalSymbol.Constructor(fullTypeName, args)))) |> List.ofSeq - (symbol, FindDeclExternalSymbol.Type fullTypeName) :: constructors + struct (symbol, FindDeclExternalSymbol.Type fullTypeName) :: constructors | :? IMethodSymbol as methsym -> FindDeclExternalParam.tryOfRoslynParameters methsym.Parameters - |> Option.map (fun args -> - symbol, FindDeclExternalSymbol.Method(container, methsym.MetadataName, args, methsym.TypeParameters.Length)) - |> Option.toList + |> ValueOption.map (fun args -> + struct (symbol, FindDeclExternalSymbol.Method(container, methsym.MetadataName, args, methsym.TypeParameters.Length))) + |> ValueOption.toList | :? IPropertySymbol as propsym -> [ @@ -110,6 +115,38 @@ module private ExternalSymbol = | _ -> [] + /// The C# or Visual Basic symbol an F# resolution points at. Its documentation comment id names + /// it outright; walking every declaration of the project is what answers for the symbols whose id + /// Roslyn cannot parse, operators among them. + let tryFind (project: Project) (targetSymbolUse: FSharpSymbolUse) targetExternalSymbol = + cancellableTask { + let! cancellationToken = CancellableTask.getCancellationToken () + let! compilation = project.GetCompilationAsync cancellationToken + + let named = + match compilation, targetSymbolUse.Symbol.DocumentationCommentId with + | null, _ + | _, ValueNone -> ValueNone + | compilation, ValueSome documentationCommentId -> + match DocumentationCommentId.GetFirstSymbolForDeclarationId(documentationCommentId, compilation) with + | null -> ValueNone + | symbol -> ValueSome symbol + + match named with + | ValueSome symbol -> return ValueSome symbol + | ValueNone -> + let! symbols = SymbolFinder.FindSourceDeclarationsAsync(project, (fun _ -> true), cancellationToken) + + return + symbols + |> Seq.collect ofRoslynSymbol + |> Seq.tryPickV (fun struct (symbol, externalSymbol) -> + if externalSymbol = targetExternalSymbol then + ValueSome symbol + else + ValueNone) + } + type internal FSharpGoToDefinitionNavigableItem(document, sourceSpan) = inherit FSharpNavigableItem(Glyph.BasicFile, ImmutableArray.Empty, document, sourceSpan) @@ -174,29 +211,26 @@ type internal GoToDefinition(metadataAsSource: FSharpMetadataAsSourceService) = && symbol1.DeclaringEntity.CompiledName = symbol2.DeclaringEntity.CompiledName | _ -> false + /// The navigable item for the range in the document, when the range fits the document's text. + let navigableItemAt (document: Document) (range: range) = + cancellableTask { + let! cancellationToken = CancellableTask.getCancellationToken () + let! sourceText = document.GetTextAsync(cancellationToken) + + return + RoslynHelpers.TryFSharpRangeToTextSpan(sourceText, range) + |> ValueOption.map (fun textSpan -> FSharpGoToDefinitionNavigableItem(document, textSpan)) + } + /// Use an origin document to provide the solution & workspace used to /// find the corresponding textSpan and INavigableItem for the range let rangeToNavigableItem (range: range, document: Document) = cancellableTask { - let fileName = - try - System.IO.Path.GetFullPath range.FileName - with _ -> - range.FileName - - let refDocumentIds = document.Project.Solution.GetDocumentIdsWithFilePath fileName - - if not refDocumentIds.IsEmpty then - let refDocumentId = refDocumentIds.First() - let refDocument = document.Project.Solution.GetDocument refDocumentId - let! cancellationToken = Async.CancellationToken - let! refSourceText = refDocument.GetTextAsync(cancellationToken) |> Async.AwaitTask - - match RoslynHelpers.TryFSharpRangeToTextSpan(refSourceText, range) with - | ValueNone -> return None - | ValueSome refTextSpan -> return Some(FSharpGoToDefinitionNavigableItem(refDocument, refTextSpan)) - else - return None + match document.TryGetSolutionDocumentFromFSharpRange range with + | ValueNone -> return None + | ValueSome refDocument -> + let! navItem = navigableItemAt refDocument range + return ValueOption.toOption navItem } member _.TryGetExternalDeclarationAsync(targetSymbolUse: FSharpSymbolUse, metadataReferences: seq) = @@ -224,9 +258,8 @@ type internal GoToDefinition(metadataAsSource: FSharpMetadataAsSourceService) = match textOpt with | None -> CancellableTask.singleton None | Some(text, fileName) -> - foregroundCancellableTask { + cancellableTask { let! cancellationToken = CancellableTask.getCancellationToken () - do! ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(cancellationToken) let tmpProjInfo, tmpDocInfo = MetadataAsSource.generateTemporaryDocument ( @@ -235,8 +268,13 @@ type internal GoToDefinition(metadataAsSource: FSharpMetadataAsSourceService) = metadataReferences ) - let tmpShownDocOpt = - metadataAsSource.ShowDocument(tmpProjInfo, tmpDocInfo.FilePath, SourceText.From(text.ToString())) + metadataAsSource.WriteDocument(tmpDocInfo.FilePath, SourceText.From(text.ToString())) + + // Opening the document is the only part that belongs to the main thread; checking the + // signature it generates is a type check like any other. + do! ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(cancellationToken) + let tmpShownDocOpt = metadataAsSource.ShowDocument(tmpProjInfo, tmpDocInfo.FilePath) + do! TaskScheduler.Default.SwitchTo() match tmpShownDocOpt with | ValueNone -> return None @@ -309,68 +347,115 @@ type internal GoToDefinition(metadataAsSource: FSharpMetadataAsSourceService) = if isSignatureFile fsSymbolUse.FileName && preferSignature = false then let fsfilePath = Path.ChangeExtension(originRange.FileName, "fs") - if not (File.Exists fsfilePath) then - return None - else - let implDoc = originDocument.Project.Solution.TryGetDocumentFromPath fsfilePath + // A file the solution does not hold cannot be navigated to, so asking it + // answers what asking the disk would, without leaving the workspace. + match originDocument.TryGetSolutionDocumentFromPath fsfilePath with + | ValueNone -> return None + | ValueSome implDoc -> + let! implSourceText = implDoc.GetTextAsync(ct) - match implDoc with - | ValueNone -> return None - | ValueSome implDoc -> - let! implSourceText = implDoc.GetTextAsync(ct) + let! _, checkFileResults = implDoc.GetFSharpParseAndCheckResultsAsync(userOpName) - let! _, checkFileResults = implDoc.GetFSharpParseAndCheckResultsAsync(userOpName) + let symbolUses = + checkFileResults.GetUsesOfSymbolInFile(symbol, cancellationToken = ct) - let symbolUses = - checkFileResults.GetUsesOfSymbolInFile(symbol, cancellationToken = ct) + let implSymbol = Array.tryHeadV symbolUses - let implSymbol = Array.tryHeadV symbolUses + match implSymbol with + | ValueNone -> return None + | ValueSome implSymbol -> + let implTextSpan = + RoslynHelpers.TryFSharpRangeToTextSpan(implSourceText, implSymbol.Range) - match implSymbol with + match implTextSpan with | ValueNone -> return None - | ValueSome implSymbol -> - let implTextSpan = - RoslynHelpers.TryFSharpRangeToTextSpan(implSourceText, implSymbol.Range) - - match implTextSpan with - | ValueNone -> return None - | ValueSome implTextSpan -> return Some(FSharpGoToDefinitionNavigableItem(implDoc, implTextSpan)) + | ValueSome implTextSpan -> return Some(FSharpGoToDefinitionNavigableItem(implDoc, implTextSpan)) else - let targetDocument = - originDocument.Project.Solution.TryGetDocumentFromFSharpRange fsSymbolUse.Range - - match targetDocument with - | None -> return None - | Some targetDocument -> - let! navItem = rangeToNavigableItem (fsSymbolUse.Range, targetDocument) - return navItem + match originDocument.TryGetSolutionDocumentFromFSharpRange fsSymbolUse.Range with + | ValueNone -> return None + | ValueSome targetDocument -> return! rangeToNavigableItem (fsSymbolUse.Range, targetDocument) } /// if the symbol is defined in the given file, return its declaration location, otherwise use the targetSymbol to find the first /// instance of its presence in the provided source file. The first case is needed to return proper declaration location for /// recursive type definitions, where the first its usage may not be the declaration. member _.FindSymbolDeclarationInDocument(targetSymbolUse: FSharpSymbolUse, document: Document) = - asyncMaybe { + cancellableTask { let filePath = document.FilePath - let! ct = Async.CancellationToken |> liftAsync - match targetSymbolUse.Symbol.DeclarationLocation with - | Some decl when decl.FileName = filePath -> return decl - | _ -> - let! _, checkFileResults = - document.GetFSharpParseAndCheckResultsAsync("FindSymbolDeclarationInDocument") - |> CancellableTask.start ct - |> Async.AwaitTask - |> liftAsync + let inThisFile = + function + | Some(range: range) when String.Equals(range.FileName, filePath, StringComparison.OrdinalIgnoreCase) -> ValueSome range + | _ -> ValueNone + + let symbol = targetSymbolUse.Symbol + + // A symbol imported from a project with signature files carries its implementation range + // beside its declaration one; either of them being this file spares a check of the file. + let knownRange = + inThisFile symbol.ImplementationLocation + |> ValueOption.orElseWith (fun () -> inThisFile symbol.DeclarationLocation) + |> ValueOption.orElseWith (fun () -> inThisFile symbol.SignatureLocation) + + match knownRange with + | ValueSome range -> return Some range + | ValueNone -> + let! _, checkFileResults = document.GetFSharpParseAndCheckResultsAsync("FindSymbolDeclarationInDocument") let symbolUses = checkFileResults.GetUsesOfSymbolInFile targetSymbolUse.Symbol - let! implSymbol = + return symbolUses - |> Array.sortByDescending (fun x -> x.IsFromDefinition) - |> Array.tryHead + |> Seq.sortByDescending _.IsFromDefinition + |> Seq.tryHeadV + |> ValueOption.map _.Range + |> ValueOption.toOption + } + + /// The navigable item for the target symbol's declaration in the implementation document. + member private this.FindNavigableDeclarationIn(targetSymbolUse: FSharpSymbolUse, implDocument: Document) = + cancellableTask { + let! declarationRange = this.FindSymbolDeclarationInDocument(targetSymbolUse, implDocument) + + match declarationRange with + | None -> return ValueNone + | Some declarationRange -> return! navigableItemAt implDocument declarationRange + } + + /// The caret is already on the declaration: in a signature file the target is the implementation, + /// in an implementation file it is the signature. + member private this.FindCounterpartOfDeclarationAtCaret + ( + originDocument: Document, + targetSymbolUse: FSharpSymbolUse, + checkFileResults: FSharpCheckFileResults, + lexerSymbol: LexerSymbol, + fcsTextLineNumber: int, + textLineString: string + ) = + cancellableTask { + if isSignatureFile originDocument.FilePath then + let implFilePath = Path.ChangeExtension(originDocument.FilePath, "fs") - return implSymbol.Range + match originDocument.TryGetSolutionDocumentFromPath implFilePath with + | ValueNone -> return ValueNone + | ValueSome implDocument -> return! this.FindNavigableDeclarationIn(targetSymbolUse, implDocument) + else + let declarations = + checkFileResults.GetDeclarationLocation( + fcsTextLineNumber, + lexerSymbol.Ident.idRange.EndColumn, + textLineString, + lexerSymbol.FullIsland, + true + ) + + match declarations with + | FindDeclResult.DeclFound sigRange -> + match originDocument.TryGetSolutionDocumentFromFSharpRange sigRange with + | ValueNone -> return ValueNone + | ValueSome sigDocument -> return! navigableItemAt sigDocument sigRange + | _ -> return ValueNone } member internal this.FindDefinitionAtPosition(originDocument: Document, position: int) = @@ -383,9 +468,6 @@ type internal GoToDefinition(metadataAsSource: FSharpMetadataAsSourceService) = let textLineString = textLine.ToString() let fcsTextLineNumber = Line.fromZ textLinePos.Line let lineText = (sourceText.Lines.GetLineFromPosition position).ToString() - - let! cancellationToken = CancellableTask.getCancellationToken () - let preferSignature = isSignatureFile originDocument.FilePath let! lexerSymbol = originDocument.TryFindFSharpLexerSymbolAsync(position, SymbolLookupKind.Greedy, false, false, userOpName) @@ -417,28 +499,16 @@ type internal GoToDefinition(metadataAsSource: FSharpMetadataAsSourceService) = match declarations with | FindDeclResult.ExternalDecl(assembly, targetExternalSym) -> let projectOpt = - originDocument.Project.Solution.Projects - |> Seq.tryFindV (fun p -> p.AssemblyName.Equals(assembly, StringComparison.OrdinalIgnoreCase)) + originDocument.TryFindInSolutions(fun solution -> + solution.Projects + |> Seq.tryFindV (fun p -> p.AssemblyName.Equals(assembly, StringComparison.OrdinalIgnoreCase))) match projectOpt with | ValueSome project -> - let! symbols = SymbolFinder.FindSourceDeclarationsAsync(project, (fun _ -> true), cancellationToken) - - let roslynSymbols = Seq.collect ExternalSymbol.ofRoslynSymbol symbols - - let symbol = - Seq.tryPickV - (fun (sym, externalSym) -> - if externalSym = targetExternalSym then - ValueSome sym - else - ValueNone) - roslynSymbols + let! symbol = ExternalSymbol.tryFind project targetSymbolUse targetExternalSym let location = - symbol - |> ValueOption.map (fun s -> s.Locations) - |> ValueOption.bind Seq.tryHeadV + symbol |> ValueOption.map _.Locations |> ValueOption.bind Seq.tryHeadV match location with | ValueNone -> return ValueNone @@ -456,122 +526,45 @@ type internal GoToDefinition(metadataAsSource: FSharpMetadataAsSourceService) = return ValueSome(FSharpGoToDefinitionResult.ExternalAssembly(targetSymbolUse, metadataReferences), idRange) | FindDeclResult.DeclFound targetRange -> - // If the file is not associated with a document, it's considered external. - if not (originDocument.Project.Solution.ContainsDocumentWithFilePath(targetRange.FileName)) then + match originDocument.TryGetSolutionDocumentFromFSharpRange targetRange with + | ValueNone -> + // No document for the file anywhere in the workspace: the symbol comes from an assembly. let metadataReferences = originDocument.Project.MetadataReferences return ValueSome(FSharpGoToDefinitionResult.ExternalAssembly(targetSymbolUse, metadataReferences), idRange) - else if - // if goto definition is called as we are already at the declaration location of a symbol in - // either a signature or an implementation file then we jump to its respective position in the document - lexerSymbol.Range = targetRange - then - // jump from signature to the corresponding implementation - if isSignatureFile originDocument.FilePath then - let implFilePath = Path.ChangeExtension(originDocument.FilePath, "fs") - - if not (File.Exists implFilePath) then - return ValueNone + | ValueSome _ when lexerSymbol.Range = targetRange -> + let! navItem = + this.FindCounterpartOfDeclarationAtCaret( + originDocument, + targetSymbolUse, + checkFileResults, + lexerSymbol, + fcsTextLineNumber, + textLineString + ) + + return + navItem + |> ValueOption.map (fun navItem -> FSharpGoToDefinitionResult.NavigableItem navItem, idRange) + | ValueSome targetDocument -> + // gotoDefn origin = signature, destination = signature; origin = implementation, destination = implementation + let! navItem = + if isSignatureFile targetRange.FileName && preferSignature then + navigableItemAt targetDocument targetRange else - let implDocument = - originDocument.Project.Solution.TryGetDocumentFromPath implFilePath - - match implDocument with - | ValueNone -> return ValueNone - | ValueSome implDocument -> - let! targetRange = this.FindSymbolDeclarationInDocument(targetSymbolUse, implDocument) - - match targetRange with - | None -> return ValueNone - | Some targetRange -> - let! implSourceText = implDocument.GetTextAsync(cancellationToken) - - let implTextSpan = - RoslynHelpers.TryFSharpRangeToTextSpan(implSourceText, targetRange) - - match implTextSpan with - | ValueNone -> return ValueNone - | ValueSome implTextSpan -> - let navItem = FSharpGoToDefinitionNavigableItem(implDocument, implTextSpan) - return ValueSome(FSharpGoToDefinitionResult.NavigableItem(navItem), idRange) - - else // jump from implementation to the corresponding signature - let declarations = - checkFileResults.GetDeclarationLocation( - fcsTextLineNumber, - idRange.EndColumn, - textLineString, - lexerSymbol.FullIsland, - true - ) + // Bugfix: apparently the target document is not always a signature file + let implFilePath = + if isSignatureFile targetDocument.FilePath then + Path.ChangeExtension(targetDocument.FilePath, "fs") + else + targetDocument.FilePath - match declarations with - | FindDeclResult.DeclFound targetRange -> - let sigDocument = - originDocument.Project.Solution.TryGetDocumentFromPath targetRange.FileName - - match sigDocument with - | ValueNone -> return ValueNone - | ValueSome sigDocument -> - let! sigSourceText = sigDocument.GetTextAsync(cancellationToken) - - let sigTextSpan = RoslynHelpers.TryFSharpRangeToTextSpan(sigSourceText, targetRange) - - match sigTextSpan with - | ValueNone -> return ValueNone - | ValueSome sigTextSpan -> - let navItem = FSharpGoToDefinitionNavigableItem(sigDocument, sigTextSpan) - return ValueSome(FSharpGoToDefinitionResult.NavigableItem(navItem), idRange) - | _ -> return ValueNone - // when the target range is different follow the navigation convention of - // - gotoDefn origin = signature , gotoDefn destination = signature - // - gotoDefn origin = implementation, gotoDefn destination = implementation - else - let sigDocument = - originDocument.Project.Solution.TryGetDocumentFromPath targetRange.FileName + match originDocument.TryGetSolutionDocumentFromPath implFilePath with + | ValueNone -> CancellableTask.singleton ValueNone + | ValueSome implDocument -> this.FindNavigableDeclarationIn(targetSymbolUse, implDocument) - match sigDocument with - | ValueNone -> return ValueNone - | ValueSome sigDocument -> - let! sigSourceText = sigDocument.GetTextAsync(cancellationToken) - let sigTextSpan = RoslynHelpers.TryFSharpRangeToTextSpan(sigSourceText, targetRange) - - match sigTextSpan with - | ValueNone -> return ValueNone - | ValueSome sigTextSpan -> - // if the gotodef call originated from a signature and the returned target is a signature, navigate there - if isSignatureFile targetRange.FileName && preferSignature then - let navItem = FSharpGoToDefinitionNavigableItem(sigDocument, sigTextSpan) - return ValueSome(FSharpGoToDefinitionResult.NavigableItem(navItem), idRange) - else // we need to get an FSharpSymbol from the targetRange found in the signature - // that symbol will be used to find the destination in the corresponding implementation file - let implFilePath = - // Bugfix: apparently sigDocument not always is a signature file - if isSignatureFile sigDocument.FilePath then - Path.ChangeExtension(sigDocument.FilePath, "fs") - else - sigDocument.FilePath - - let implDocument = - originDocument.Project.Solution.TryGetDocumentFromPath implFilePath - - match implDocument with - | ValueNone -> return ValueNone - | ValueSome implDocument -> - let! targetRange = this.FindSymbolDeclarationInDocument(targetSymbolUse, implDocument) - - match targetRange with - | None -> return ValueNone - | Some targetRange -> - let! implSourceText = implDocument.GetTextAsync(cancellationToken) - - let implTextSpan = - RoslynHelpers.TryFSharpRangeToTextSpan(implSourceText, targetRange) - - match implTextSpan with - | ValueNone -> return ValueNone - | ValueSome implTextSpan -> - let navItem = FSharpGoToDefinitionNavigableItem(implDocument, implTextSpan) - return ValueSome(FSharpGoToDefinitionResult.NavigableItem(navItem), idRange) + return + navItem + |> ValueOption.map (fun navItem -> FSharpGoToDefinitionResult.NavigableItem navItem, idRange) | _ -> return ValueNone } @@ -646,80 +639,6 @@ type internal GoToDefinition(metadataAsSource: FSharpMetadataAsSourceService) = | None -> return false } - member this.NavigateToExternalDeclaration - (targetSymbolUse: FSharpSymbolUse, metadataReferences: seq, cancellationToken: CancellationToken) - = - - let textOpt = - match targetSymbolUse.Symbol with - | :? FSharpEntity as symbol -> symbol.TryGetMetadataText() |> Option.map (fun text -> text, symbol.DisplayName) - | :? FSharpMemberOrFunctionOrValue as symbol -> - symbol.ApparentEnclosingEntity - |> Option.bind (fun entity -> entity.TryGetMetadataText() |> Option.map (fun text -> text, entity.DisplayName)) - | :? FSharpField as symbol -> - match symbol.DeclaringEntity with - | Some entity -> - let text = entity.TryGetMetadataText() - - match text with - | Some text -> Some(text, entity.DisplayName) - | None -> None - | None -> None - | :? FSharpUnionCase as symbol -> - symbol.DeclaringEntity.TryGetMetadataText() - |> Option.map (fun text -> text, symbol.DisplayName) - | _ -> None - - match textOpt with - | Some(text, fileName) -> - let tmpProjInfo, tmpDocInfo = - MetadataAsSource.generateTemporaryDocument ( - AssemblyIdentity(targetSymbolUse.Symbol.Assembly.QualifiedName), - fileName, - metadataReferences - ) - - let tmpShownDocOpt = - metadataAsSource.ShowDocument(tmpProjInfo, tmpDocInfo.FilePath, SourceText.From(text.ToString())) - - match tmpShownDocOpt with - | ValueSome tmpShownDoc -> - let goToAsync = - cancellableTask { - - let! cancellationToken = CancellableTask.getCancellationToken () - - let! _, checkResults = tmpShownDoc.GetFSharpParseAndCheckResultsAsync("NavigateToExternalDeclaration") - - let r = - // This tries to find the best possible location of the target symbol's location in the metadata source. - // We really should rely on symbol equality within FCS instead of doing it here, - // but the generated metadata as source isn't perfect for symbol equality. - let symbols = checkResults.GetAllUsesOfAllSymbolsInFile(cancellationToken) - - symbols - |> Seq.tryFindV (tryFindExternalSymbolUse targetSymbolUse) - |> ValueOption.map (fun x -> x.Range) - |> ValueOption.toOption - - match r with - | None -> return TextSpan.empty - | Some r -> - let! text = tmpShownDoc.GetTextAsync(cancellationToken) - - match RoslynHelpers.TryFSharpRangeToTextSpan(text, r) with - | ValueSome span -> return span - | _ -> return TextSpan.empty - - } - - let span = CancellableTask.runSynchronously cancellationToken goToAsync - - let navItem = FSharpGoToDefinitionNavigableItem(tmpShownDoc, span) - this.NavigateToItem(navItem, cancellationToken) - | _ -> false - | _ -> false - type internal FSharpNavigation(metadataAsSource: FSharpMetadataAsSourceService, initialDoc: Document, thisSymbolUseRange: range) = let workspace = initialDoc.Project.Solution.Workspace @@ -731,53 +650,47 @@ type internal FSharpNavigation(metadataAsSource: FSharpMetadataAsSourceService, && solution.TryGetDocumentIdFromFSharpRange(range, initialDoc.Project.Id) |> Option.isSome + /// Follows a link in a QuickInfo tooltip, which the user clicks on the main thread. Nothing waits + /// for the result, so the search runs in the background and only the navigation comes back here. member _.NavigateTo(range: range) = - try - ThreadHelper.JoinableTaskFactory.Run( - SR.NavigatingTo(), - (fun _progress cancellationToken -> - cancellableTask { - let targetDoc = solution.TryGetDocumentFromFSharpRange(range, initialDoc.Project.Id) + let navigation = + ThreadHelper.JoinableTaskFactory.RunAsync(fun () -> + cancellableTask { + let targetDoc = solution.TryGetDocumentFromFSharpRange(range, initialDoc.Project.Id) - match targetDoc with - | None -> () - | Some targetDoc -> + match targetDoc with + | None -> () + | Some targetDoc -> - let! cancellationToken = CancellableTask.getCancellationToken () + let! cancellationToken = CancellableTask.getCancellationToken () + + let! targetSource = targetDoc.GetTextAsync(cancellationToken) + let targetTextSpan = RoslynHelpers.TryFSharpRangeToTextSpan(targetSource, range) - let! targetSource = targetDoc.GetTextAsync(cancellationToken) - let targetTextSpan = RoslynHelpers.TryFSharpRangeToTextSpan(targetSource, range) + match targetTextSpan with + | ValueNone -> () + | ValueSome targetTextSpan -> - match targetTextSpan with - | ValueNone -> () - | ValueSome targetTextSpan -> + let gtd = GoToDefinition(metadataAsSource) - let gtd = GoToDefinition(metadataAsSource) + // Whenever possible: + // - signature files (.fsi) should navigate to other signature files + // - implementation files (.fs) should navigate to other implementation files + if isSignatureFile initialDoc.FilePath then + // Target range will point to .fsi file if only there is one so we can just use Roslyn navigation service. + do gtd.TryNavigateToTextSpan(targetDoc, targetTextSpan, cancellationToken) + else + // Navigation request was made in a .fs file, so we try to find the implementation of the symbol at target range. + // This is the part that may take some time, because of type checks involved. + let! result = gtd.NavigateToSymbolDefinitionAsync(targetDoc, targetSource, range) - // Whenever possible: - // - signature files (.fsi) should navigate to other signature files - // - implementation files (.fs) should navigate to other implementation files - if isSignatureFile initialDoc.FilePath then - // Target range will point to .fsi file if only there is one so we can just use Roslyn navigation service. + if not result then + // In case the above fails, we just navigate to target range. do gtd.TryNavigateToTextSpan(targetDoc, targetTextSpan, cancellationToken) - else - // Navigation request was made in a .fs file, so we try to find the implementation of the symbol at target range. - // This is the part that may take some time, because of type checks involved. - let! result = gtd.NavigateToSymbolDefinitionAsync(targetDoc, targetSource, range) + } + |> CancellableTask.startAsTaskWithoutCancellation) - if not result then - // In case the above fails, we just navigate to target range. - do gtd.TryNavigateToTextSpan(targetDoc, targetTextSpan, cancellationToken) - } - |> CancellableTask.start cancellationToken), - // Default wait time before VS shows the dialog allowing to cancel the long running task is 2 seconds. - // This seems a bit too long to leave the user without any feedback, so we shorten it to 1 second. - // Note: it seems anything less than 1 second will get rounded down to zero, resulting in flashing dialog - // on each navigation, so 1 second is as low as we cen get from JoinableTaskFactory. - TimeSpan.FromSeconds 1 - ) - with :? OperationCanceledException -> - () + navigation.FileAndForget "fsharp/navigateToQuickInfoTarget" member _.FindDefinitionsAsync(position) = cancellableTask { @@ -794,267 +707,41 @@ type internal FSharpNavigation(metadataAsSource: FSharpMetadataAsSourceService, } member _.TryGoToDefinition(position, cancellationToken) = - // Once we migrate to Roslyn-exposed MAAS and sourcelink (https://github.com/dotnet/fsharp/issues/13951), this can be a "normal" task - // Wrap this in a try/with as if the user clicks "Cancel" on the thread dialog, we'll be cancelled. - // Task.Wait throws an exception if the task is cancelled, so be sure to catch it. + // Once we migrate to Roslyn-exposed MAAS and sourcelink (https://github.com/dotnet/fsharp/issues/13951), this can be a "normal" task. + // The IFSharpGoToDefinitionService contract is synchronous, so the main thread has to wait here: the threaded-wait dialog + // keeps it pumping and cancellable, where a bare Task.Wait froze it until the VS watchdog auto-cancelled. try use _ = TelemetryReporter.ReportSingleEventWithDuration(TelemetryEvents.GoToDefinition, [||]) let gtd = GoToDefinition(metadataAsSource) - let gtdTask = gtd.FindDefinitionAsync (initialDoc, position) cancellationToken + let navigated = ref false - gtdTask.Wait() + ThreadHelper.JoinableTaskFactory.Run( + SR.NavigatingTo(), + (fun _progress dialogCancellationToken -> + let linked = + CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, dialogCancellationToken) - if gtdTask.Status = TaskStatus.RanToCompletion && gtdTask.Result.IsSome then - match gtdTask.Result with - | ValueSome(FSharpGoToDefinitionResult.NavigableItem(navItem), _) -> - gtd.NavigateToItem(navItem, cancellationToken) |> ignore - true - | ValueSome(FSharpGoToDefinitionResult.ExternalAssembly(targetSymbolUse, metadataReferences), _) -> - gtd.NavigateToExternalDeclaration(targetSymbolUse, metadataReferences, cancellationToken) - |> ignore + cancellableTask { + use _ = linked + + match! gtd.FindDefinitionAsync(initialDoc, position) with + | ValueSome(FSharpGoToDefinitionResult.NavigableItem(navItem), _) -> + gtd.NavigateToItem(navItem, linked.Token) |> ignore + navigated.Value <- true + | ValueSome(FSharpGoToDefinitionResult.ExternalAssembly(targetSymbolUse, metadataReferences), _) -> + let! result = gtd.NavigateToExternalDeclarationAsync(targetSymbolUse, metadataReferences) + navigated.Value <- result + | _ -> () + } + |> CancellableTask.start linked.Token), + TimeSpan.FromSeconds 1 + ) - true - | _ -> false - else - false - with exc -> + navigated.Value + with + | :? OperationCanceledException -> false + | exc -> TelemetryReporter.ReportFault(TelemetryEvents.GoToDefinition, FaultSeverity.General, exc) false - -[] -type internal SymbolMemberType = - | Event - | Property - | Method - | Constructor - | Other - -type internal SymbolPath = - { - EntityPath: string list - MemberOrValName: string - GenericParameters: int - } - -[] -type internal DocCommentId = - | Member of SymbolPath * SymbolMemberType: SymbolMemberType - | Field of SymbolPath - | Type of EntityPath: string list - | None - -type FSharpNavigableLocation(metadataAsSource: FSharpMetadataAsSourceService, symbolRange: range, project: Project) = - interface IFSharpNavigableLocation with - member _.NavigateToAsync(_options: FSharpNavigationOptions2, cancellationToken: CancellationToken) : Task = - cancellableTask { - let targetPath = symbolRange.FileName - - let! cancellationToken = CancellableTask.getCancellationToken () - - let targetDoc = - project.Solution.TryGetDocumentFromFSharpRange(symbolRange, project.Id) - - match targetDoc with - | None -> return false - | Some targetDoc -> - let! targetSource = targetDoc.GetTextAsync(cancellationToken) - let gtd = GoToDefinition(metadataAsSource) - - let (|Signature|Implementation|) filepath = - if isSignatureFile filepath then - Signature - else - Implementation - - match targetPath with - | Signature -> return! gtd.NavigateToSymbolDefinitionAsync(targetDoc, targetSource, symbolRange) - | Implementation -> return! gtd.NavigateToSymbolDeclarationAsync(targetDoc, targetSource, symbolRange) - } - |> CancellableTask.start cancellationToken - -[)>] -[)>] -type FSharpCrossLanguageSymbolNavigationService() = - let componentModel = - Package.GetGlobalService(typeof) :?> ComponentModelHost.IComponentModel - - let workspace = componentModel.GetService() - - let metadataAsSource = - componentModel.DefaultExportProvider.GetExport().Value - - let tryFindFieldByName (name: string) (e: FSharpEntity) = - let fields = - e.FSharpFields - |> Seq.filter (fun x -> x.DisplayName = name && not x.IsCompilerGenerated) - |> Seq.map (fun e -> e.DeclarationLocation) - - if fields.Count() <= 0 && (e.IsFSharpUnion || e.IsFSharpRecord) then - Seq.singleton e.DeclarationLocation - else - fields - - let tryFindValByNameAndType - (name: string) - (symbolMemberType: SymbolMemberType) - (genericParametersCount: int) - (e: FSharpEntity) - (entities: FSharpMemberOrFunctionOrValue seq) - = - - let defaultFilter (e: FSharpMemberOrFunctionOrValue) = - (e.DisplayName = name || e.CompiledName = name) - && e.GenericParameters.Count = genericParametersCount - - let isProperty (e: FSharpMemberOrFunctionOrValue) = defaultFilter e && e.IsProperty - let isConstructor (e: FSharpMemberOrFunctionOrValue) = defaultFilter e && e.IsConstructor - - let getLocation (e: FSharpMemberOrFunctionOrValue) = e.DeclarationLocation - - let filteredEntities: range seq = - match symbolMemberType with - | SymbolMemberType.Other - | SymbolMemberType.Method -> entities |> Seq.filter defaultFilter |> Seq.map getLocation - // F# record-specific logic, if navigating to the record's ctor, then navigate to record declaration. - // If we navigating to F# record property, we first check if it's "custom" property, if it's one of the record fields, we search for it in the fields. - | SymbolMemberType.Constructor when e.IsFSharpRecord -> Seq.singleton e.DeclarationLocation - | SymbolMemberType.Property when e.IsFSharpRecord -> - let properties = entities |> Seq.filter isProperty |> Seq.map getLocation - let fields = tryFindFieldByName name e - Seq.append properties fields - | SymbolMemberType.Constructor -> entities |> Seq.filter isConstructor |> Seq.map getLocation - // When navigating to property for the record, it will be in members bag for custom ones, but will be in the fields in fields. - | SymbolMemberType.Event // Events are just properties` - | SymbolMemberType.Property -> entities |> Seq.filter isProperty |> Seq.map getLocation - - filteredEntities - - let tryFindVal - (name: string) - (documentCommentId: string) - (symbolMemberType: SymbolMemberType) - (genericParametersCount: int) - (e: FSharpEntity) - = - let entities = e.TryGetMembersFunctionsAndValues() - - // First, try and find entity by exact xml signature, return if found, - // otherwise, just try and match by parsed name and number of arguments. - - let entitiesByXmlSig = - entities - |> Seq.filter (fun e -> e.XmlDocSig = documentCommentId) - |> Seq.map (fun e -> e.DeclarationLocation) - - if Seq.isEmpty entitiesByXmlSig then - tryFindValByNameAndType name symbolMemberType genericParametersCount e entities - else - entitiesByXmlSig - - /// Convert a documentation comment ID to a navigation path. - /// Uses the shared XmlDocSigParser from FSharp.Compiler.Symbols. - static member internal DocCommentIdToPath(docId: string) = - // Use the shared parser from FSharp.Compiler.Symbols - match XmlDocSigParser.parseDocCommentId docId with - | ParsedDocCommentId.Type path -> DocCommentId.Type path - - | ParsedDocCommentId.Member(typePath, memberName, genericArity, kind) -> - // Convert constructor name format (.ctor in parser, ``.ctor`` needed for F# lookup) - let memberOrValName = if memberName = ".ctor" then "``.ctor``" else memberName - - let symbolMemberType = - match kind with - | DocCommentIdKind.Method -> - if memberName = ".ctor" then - SymbolMemberType.Constructor - else - SymbolMemberType.Method - | DocCommentIdKind.Property -> SymbolMemberType.Property - | DocCommentIdKind.Event -> SymbolMemberType.Event - | _ -> SymbolMemberType.Other - - DocCommentId.Member( - { - EntityPath = typePath - MemberOrValName = memberOrValName - GenericParameters = genericArity - }, - symbolMemberType - ) - - | ParsedDocCommentId.Field(typePath, fieldName) -> - DocCommentId.Field - { - EntityPath = typePath - MemberOrValName = fieldName - GenericParameters = 0 - } - - | ParsedDocCommentId.None -> DocCommentId.None - - interface IFSharpCrossLanguageSymbolNavigationService with - member _.TryGetNavigableLocationAsync - (assemblyName: string, documentationCommentId: string, cancellationToken: CancellationToken) - : Task = - let path = - FSharpCrossLanguageSymbolNavigationService.DocCommentIdToPath documentationCommentId - - cancellableTask { - let projects = - workspace.CurrentSolution.Projects - |> Seq.filter (fun p -> p.IsFSharp && p.AssemblyName = assemblyName) - - let mutable locations = Seq.empty - - for project in projects do - let! checker, _, _, options = project.GetFSharpCompilationOptionsAsync() - let! result = checker.ParseAndCheckProject(options) - - match path with - | DocCommentId.Member({ - EntityPath = entityPath - MemberOrValName = memberOrVal - GenericParameters = genericParametersCount - }, - memberType) -> - let entity = result.AssemblySignature.FindEntityByPath(entityPath) - - entity - |> Option.iter (fun e -> - locations <- - e - |> tryFindVal memberOrVal documentationCommentId memberType genericParametersCount - |> Seq.map (fun m -> (m, project)) - |> Seq.append locations) - | DocCommentId.Field { - EntityPath = entityPath - MemberOrValName = memberOrVal - } -> - let entity = result.AssemblySignature.FindEntityByPath(entityPath) - - entity - |> Option.iter (fun e -> - locations <- - e - |> tryFindFieldByName memberOrVal - |> Seq.map (fun m -> (m, project)) - |> Seq.append locations) - | DocCommentId.Type entityPath -> - let entity = result.AssemblySignature.FindEntityByPath(entityPath) - - entity - |> Option.iter (fun e -> locations <- Seq.append locations [ e.DeclarationLocation, project ]) - | DocCommentId.None -> () - - // TODO: Figure out the way of giving the user choice where to navigate, if there are more than one result - // For now, we only take 1st one, since it's usually going to be only one result (given we process names correctly). - // More results can theoretically be returned in case of method overloads, or when we have both signature and implementation files. - if locations.Count() >= 1 then - let (location, project) = locations.First() - return FSharpNavigableLocation(metadataAsSource, location, project) :> IFSharpNavigableLocation - else - return Unchecked.defaultof<_> // returning null here, so Roslyn can fallback to default source-as-metadata implementation. - } - |> CancellableTask.start cancellationToken diff --git a/vsintegration/src/FSharp.Editor/Navigation/GoToDefinitionService.fs b/vsintegration/src/FSharp.Editor/Navigation/GoToDefinitionService.fs index 76233f6fe7e..dd351296c9a 100644 --- a/vsintegration/src/FSharp.Editor/Navigation/GoToDefinitionService.fs +++ b/vsintegration/src/FSharp.Editor/Navigation/GoToDefinitionService.fs @@ -17,17 +17,17 @@ open System.Collections.Generic type internal FSharpGoToDefinitionService [] (metadataAsSource: FSharpMetadataAsSourceService) = interface IFSharpGoToDefinitionService with - /// Invoked with Peek Definition. - member _.FindDefinitionsAsync(document: Document, position: int, _cancellationToken: CancellationToken) = + /// Where Go To Definition and Peek Definition arrive: Roslyn searches in the background and + /// navigates to the first item itself, cancelling as the caret moves or the user types. + member _.FindDefinitionsAsync(document: Document, position: int, cancellationToken: CancellationToken) = cancellableTask { let navigation = FSharpNavigation(metadataAsSource, document, rangeStartup) let! res = navigation.FindDefinitionsAsync(position) return (res :> IEnumerable<_>) } - |> CancellableTask.startWithoutCancellation + |> CancellableTask.start cancellationToken - /// Invoked with Go to Definition. - /// Try to navigate to the definition of the symbol at the symbolRange in the originDocument + /// The synchronous contract older Roslyn hosts navigate through. member _.TryGoToDefinition(document: Document, position: int, cancellationToken: CancellationToken) = let navigation = FSharpNavigation(metadataAsSource, document, rangeStartup) navigation.TryGoToDefinition(position, cancellationToken) diff --git a/vsintegration/src/FSharp.Editor/Navigation/NavigableSymbolsService.fs b/vsintegration/src/FSharp.Editor/Navigation/NavigableSymbolsService.fs index ac954d2d278..c159aea0f64 100644 --- a/vsintegration/src/FSharp.Editor/Navigation/NavigableSymbolsService.fs +++ b/vsintegration/src/FSharp.Editor/Navigation/NavigableSymbolsService.fs @@ -6,6 +6,8 @@ open System open System.Threading open System.Threading.Tasks open System.ComponentModel.Composition +open Microsoft.VisualStudio.Shell +open Microsoft.VisualStudio.Threading open Microsoft.CodeAnalysis.Text open Microsoft.CodeAnalysis.ExternalAccess.FSharp.Navigation @@ -69,15 +71,16 @@ type internal FSharpNavigableSymbolSource(metadataAsSource) = let nav = { new INavigableSymbol with member _.Navigate(_: INavigableRelationship) = - // Need to new up a CTS here instead of re-using the other one, since VS - // will navigate disconnected from the outer routine, leading to an - // OperationCancelledException if you use the one defined outside. - // TODO: review if it's a proper way of doing it - use ct = new CancellationTokenSource() - - do - gtd.NavigateToExternalDeclaration(targetSymbolUse, metadataReferences, ct.Token) - |> ignore + // Ctrl+click calls this on the main thread and gives us no token of its + // own: generating the signature and checking it belong to the background, + // and only opening the document comes back here. + let navigation = + ThreadHelper.JoinableTaskFactory.RunAsync(fun () -> + gtd.NavigateToExternalDeclarationAsync(targetSymbolUse, metadataReferences) + |> CancellableTask.startWithoutCancellation + :> Task) + + navigation.FileAndForget "fsharp/navigateToExternalDeclaration" member _.Relationships = seq { yield PredefinedNavigableRelationships.Definition } diff --git a/vsintegration/src/FSharp.Editor/Navigation/NavigateToSearchService.fs b/vsintegration/src/FSharp.Editor/Navigation/NavigateToSearchService.fs index 546b00e1b16..a6319be550d 100644 --- a/vsintegration/src/FSharp.Editor/Navigation/NavigateToSearchService.fs +++ b/vsintegration/src/FSharp.Editor/Navigation/NavigateToSearchService.fs @@ -7,8 +7,9 @@ open System.IO open System.Composition open System.Collections.Immutable open System.Collections.Concurrent -open System.Threading.Tasks open System.Globalization +open System.Linq +open System.Threading.Tasks open Microsoft.CodeAnalysis open Microsoft.CodeAnalysis.ExternalAccess.FSharp.Navigation @@ -17,36 +18,157 @@ open Microsoft.VisualStudio.LanguageServices open Microsoft.VisualStudio.Text.PatternMatching open FSharp.Compiler.EditorServices +open FSharp.Compiler.Syntax open CancellableTasks -[); Shared>] -type internal FSharpNavigateToSearchService +/// Parse-tree navigable items per document, cached on the document's text version. +/// Shared by NavigateTo and by the Copilot chat mention provider. +[] +type internal FSharpNavigableItemsCache [] (patternMatcherFactory: IPatternMatcherFactory, [] workspace: VisualStudioWorkspace) = - let cache = ConcurrentDictionary() + let cache = + ConcurrentDictionary< + DocumentId, + struct {| + Version: VersionStamp + Approximate: bool + Items: NavigableItem array + |} + >() + + /// Whether the file's parse depends on the defines, by file path: known once any instance has parsed it. + let conditionalDirectives = + ConcurrentDictionary(StringComparer.OrdinalIgnoreCase) do - if workspace <> null then - workspace.WorkspaceChanged.Add - <| fun e -> + match workspace with + | null -> () + | workspace -> + workspace.WorkspaceChanged.Add(fun e -> if e.NewSolution.Id <> e.OldSolution.Id then cache.Clear() + conditionalDirectives.Clear()) + + let hasConditionalDirectives (parseTree: ParsedInput) = + match parseTree with + | ParsedInput.ImplFile file -> not file.Trivia.ConditionalDirectives.IsEmpty + | ParsedInput.SigFile file -> not file.Trivia.ConditionalDirectives.IsEmpty + + let store (document: Document) version approximate (parseTree: ParsedInput) = + let items = NavigateTo.GetNavigableItems parseTree + + cache[document.Id] <- + struct {| + Version = version + Approximate = approximate + Items = items + |} + + match document.FilePath with + | null -> () + | path -> conditionalDirectives[path] <- hasConditionalDirectives parseTree + + items + + member _.GetNavigableItems(document: Document) = + cancellableTask { + let! ct = CancellableTask.getCancellationToken () + let! currentVersion = document.GetTextVersionAsync(ct) - let getNavigableItems (document: Document) = + match cache.TryGetValue document.Id with + | true, entry when entry.Version = currentVersion && not entry.Approximate -> return entry.Items + | _ -> + let! parseResults = document.GetFSharpParseResultsAsync(nameof (FSharpNavigableItemsCache)) + return store document currentVersion false parseResults.ParseTree + } + + /// The items of a parse that does not wait for the project's compilation options, for the search that runs while + /// the solution is still loading. A file behind `#if` can be read under the wrong defines, so the entry it leaves + /// behind never answers `GetNavigableItems`. + member _.GetNavigableItemsWhileLoading(document: Document) = cancellableTask { let! ct = CancellableTask.getCancellationToken () let! currentVersion = document.GetTextVersionAsync(ct) match cache.TryGetValue document.Id with - | true, (version, items) when version = currentVersion -> return items + | true, entry when entry.Version = currentVersion -> return entry.Items | _ -> - let! parseResults = document.GetFSharpParseResultsAsync(nameof (FSharpNavigateToSearchService)) - let items = NavigateTo.GetNavigableItems parseResults.ParseTree - cache[document.Id] <- currentVersion, items - return items + let! parseResults = document.GetFSharpQuickParseResultsAsync(nameof (FSharpNavigableItemsCache)) + return store document currentVersion true parseResults.ParseTree } + /// The items of the document's last parse, whatever version they came from. Reads no text, so a + /// closed document costs nothing; a caller that needs the items of the current text asks for them. + member _.TryGetCachedNavigableItems(documentId: DocumentId) = + match cache.TryGetValue documentId with + | true, entry -> ValueSome entry.Items + | _ -> ValueNone + + /// Whether the file's parse tree, from whichever instance parsed it first, has `#if` directives. + /// `true` when unknown, so a file no instance has parsed yet is searched everywhere. + member _.HasConditionalDirectives(filePath: string) = + match conditionalDirectives.TryGetValue filePath with + | true, dependsOnDefines -> dependsOnDefines + | _ -> true + + member _.CreateMatcherFor(searchPattern: string) = + let patternMatcher = + patternMatcherFactory.CreatePatternMatcher( + searchPattern, + PatternMatcherCreationOptions( + cultureInfo = CultureInfo.CurrentUICulture, + flags = PatternMatcherCreationFlags.AllowFuzzyMatching, + containerSplitCharacters = [ '.' ] + ) + ) + + fun (item: NavigableItem) -> + // PatternMatcher will not match operators and some backtick escaped identifiers. + // To handle them, we fall back to simple substring match. + let name = item.Name + + if item.NeedsBackticks then + match name.IndexOf(searchPattern, StringComparison.CurrentCultureIgnoreCase) with + | i when i > 0 -> ValueSome(PatternMatch(PatternMatchKind.Substring, false, false)) + | 0 when name.Length = searchPattern.Length -> ValueSome(PatternMatch(PatternMatchKind.Exact, false, false)) + | 0 -> ValueSome(PatternMatch(PatternMatchKind.Prefix, false, false)) + | _ -> ValueNone + else + // full name with dots allows for path matching, e.g. + // "f.c.so.elseif" will match "Fantomas.Core.SyntaxOak.ElseIfNode" + patternMatcher.TryMatch $"{item.Container.FullName}.{name}" + |> ValueOption.ofNullable + +[); Shared>] +type internal FSharpNavigateToSearchService [] (itemsCache: FSharpNavigableItemsCache) = + + /// A multi-targeted project is one Roslyn project per target framework over the same files. The + /// first instance in the solution searches every file; the others only the files they alone compile + /// and the files whose parse depends on the defines. + let searchedIn (project: Project) = + match project.FilePath with + | null -> fun (_: Document) -> true + | projectPath -> + let instances = + project.Solution.Projects + |> Seq.filter (fun p -> p.FilePath = projectPath) + |> Seq.map _.Id + |> Seq.toArray + + fun (document: Document) -> + match document.FilePath with + | null -> true + | path -> + let documentIds = project.Solution.GetDocumentIdsWithFilePath path + + let owner = + instances + |> Array.find (fun id -> documentIds |> Seq.exists (fun documentId -> documentId.ProjectId = id)) + + owner = project.Id || itemsCache.HasConditionalDirectives path + let kindsProvided = ImmutableHashSet.Create( FSharpNavigateToItemKind.Module, @@ -115,111 +237,140 @@ type internal FSharpNavigateToSearchService | PatternMatchKind.Fuzzy -> FSharpNavigateToMatchKind.Fuzzy | _ -> FSharpNavigateToMatchKind.None - let createMatcherFor searchPattern = - let patternMatcher = - patternMatcherFactory.CreatePatternMatcher( - searchPattern, - PatternMatcherCreationOptions( - cultureInfo = CultureInfo.CurrentUICulture, - flags = PatternMatcherCreationFlags.AllowFuzzyMatching, - containerSplitCharacters = [ '.' ] - ) - ) + let createMatcherFor (searchPattern: string) = + itemsCache.CreateMatcherFor searchPattern - fun (item: NavigableItem) -> - // PatternMatcher will not match operators and some backtick escaped identifiers. - // To handle them, we fall back to simple substring match. - let name = item.Name - - if item.NeedsBackticks then - match name.IndexOf(searchPattern, StringComparison.CurrentCultureIgnoreCase) with - | i when i > 0 -> ValueSome(PatternMatch(PatternMatchKind.Substring, false, false)) - | 0 when name.Length = searchPattern.Length -> ValueSome(PatternMatch(PatternMatchKind.Exact, false, false)) - | 0 -> ValueSome(PatternMatch(PatternMatchKind.Prefix, false, false)) - | _ -> ValueNone - else - // full name with dots allows for path matching, e.g. - // "f.c.so.elseif" will match "Fantomas.Core.SyntaxOak.ElseIfNode" - patternMatcher.TryMatch $"{item.Container.FullName}.{name}" - |> ValueOption.ofNullable - - let processDocument (tryMatch: NavigableItem -> PatternMatch voption) (kinds: IImmutableSet) (document: Document) = + let processDocument + (getItems: Document -> CancellableTask) + (tryMatch: NavigableItem -> PatternMatch voption) + (kinds: IImmutableSet) + (document: Document) + = cancellableTask { - let! ct = CancellableTask.getCancellationToken () + let! items = getItems document - let! sourceText = document.GetTextAsync ct - - let! items = getNavigableItems document - - let processed = + let matches = [| for item in items do - let contains = kinds.Contains(navigateToItemKindToRoslynKind item.Kind) - let patternMatch = tryMatch item + if kinds.Contains(navigateToItemKindToRoslynKind item.Kind) then + match tryMatch item with + | ValueSome m -> yield struct (item, m) + | ValueNone -> () + |] - match contains, patternMatch with - | true, ValueSome m -> - let sourceSpan = RoslynHelpers.TryFSharpRangeToTextSpan(sourceText, item.Range) + // The text, read from disk for a closed document, is only needed to place the matches. + if matches.Length = 0 then + return ImmutableArray.Empty + else + let! ct = CancellableTask.getCancellationToken () + let! sourceText = document.GetTextAsync ct - match sourceSpan with + return + [| + for struct (item, m) in matches do + match RoslynHelpers.TryFSharpRangeToTextSpan(sourceText, item.Range) with | ValueNone -> () | ValueSome sourceSpan -> - let glyph = navigateToItemKindToGlyph item.Kind - let kind = navigateToItemKindToRoslynKind item.Kind - let additionalInfo = formatInfo item.Container document - yield FSharpNavigateToSearchResult( - additionalInfo, - kind, + formatInfo item.Container document, + navigateToItemKindToRoslynKind item.Kind, patternMatchKindToNavigateToMatchKind m.Kind, item.Name, FSharpNavigableItem( - glyph, + navigateToItemKindToGlyph item.Kind, ImmutableArray.Create(TaggedText(TextTags.Text, item.Name)), document, sourceSpan ) ) - | _ -> () - |] - - return processed + |] + |> ImmutableArray.CreateRange } - interface IFSharpNavigateToSearchService with - member _.SearchProjectAsync - (project, _priorityDocuments, searchPattern, kinds, cancellationToken) - : Task> = - cancellableTask { - let tryMatch = createMatcherFor searchPattern + let searchProject (project: Project) searchPattern kinds = + cancellableTask { + let tryMatch = createMatcherFor searchPattern - let tasks = - [| - for doc in project.Documents do - yield processDocument tryMatch kinds doc - |] + let! results = + project.Documents + |> Seq.filter (searchedIn project) + |> Seq.map (processDocument itemsCache.GetNavigableItems tryMatch kinds) + // Throttle to avoid launching a parse per document in the project all at once. + |> CancellableTask.whenAllThrottled (max 1 Environment.ProcessorCount) - let! results = CancellableTask.whenAll tasks + return results |> Seq.collect _.AsEnumerable() |> Seq.toImmutableArray + } - let results' = ImmutableArray.CreateBuilder() + /// Priority items first, each half in its original order, as NavigateTo's own service orders its work. + let prioritize isPriority items = + let priority, rest = items |> Seq.toArray |> Array.partition isPriority + [| yield! priority; yield! rest |] - for navResults in results do - for navResult in navResults do - results'.Add navResult + /// A parse behind the loading search takes a turn on the budget every search shares. + let throttled (work: CancellableTask<'a>) = + cancellableTask { + let! ct = CancellableTask.getCancellationToken () + do! SymbolHelpers.searchThrottle.WaitAsync ct - return results'.ToImmutable() + try + return! work + finally + SymbolHelpers.searchThrottle.Release() |> ignore + } - } + interface IFSharpNavigateToSearchService with + member _.SearchProjectAsync + (project, _priorityDocuments, searchPattern, kinds, cancellationToken) + : Task> = + searchProject project searchPattern kinds |> CancellableTask.start cancellationToken member _.SearchDocumentAsync(document: Document, searchPattern, kinds, cancellationToken) = - cancellableTask { - let! result = processDocument (createMatcherFor searchPattern) kinds document - return Array.toImmutableArray result - } - |> CancellableTask.start cancellationToken + processDocument itemsCache.GetNavigableItems (createMatcherFor searchPattern) kinds document cancellationToken member _.KindsProvided = kindsProvided member _.CanFilter = true + + interface IFSharpAdvancedNavigateToSearchService with + member _.SearchCachedDocumentsAsync + ( + _solution, + projects, + priorityDocuments, + searchPattern, + kinds, + _activeDocument, + onResultsFound, + onProjectCompleted, + cancellationToken + ) : Task = + let tryMatch = createMatcherFor searchPattern + let priorityIds = ImmutableHashSet.CreateRange(priorityDocuments |> Seq.map _.Id) + let isPriority (document: Document) = priorityIds.Contains document.Id + + let searchDocumentWhileLoading document = + cancellableTask { + let! results = throttled (processDocument itemsCache.GetNavigableItemsWhileLoading tryMatch kinds document) + + if results.Length > 0 then + do! onResultsFound.Invoke results + } + + let searchProjectWhileLoading (project: Project) = + cancellableTask { + do! + project.Documents + |> Seq.filter (searchedIn project) + |> prioritize isPriority + |> CancellableTask.forEachThrottled Environment.ProcessorCount searchDocumentWhileLoading + + do! onProjectCompleted.Invoke() + } + + projects + |> prioritize (fun project -> project.Documents |> Seq.exists isPriority) + |> Seq.map searchProjectWhileLoading + |> CancellableTask.whenAll + |> CancellableTask.startAsTask cancellationToken diff --git a/vsintegration/src/FSharp.Editor/Structure/BlockStructureService.fs b/vsintegration/src/FSharp.Editor/Structure/BlockStructureService.fs index d0326d84311..2eb47b09971 100644 --- a/vsintegration/src/FSharp.Editor/Structure/BlockStructureService.fs +++ b/vsintegration/src/FSharp.Editor/Structure/BlockStructureService.fs @@ -119,7 +119,7 @@ module internal BlockStructure = let ellipsis = "..." let createBlockSpans isBlockStructureEnabled (sourceText: SourceText) (parsedInput: ParsedInput) = - let linetext = sourceText.Lines |> Seq.map (fun x -> x.ToString()) |> Seq.toArray + let linetext = sourceText.GetLinesAsMemory() Structure.getOutliningRanges linetext parsedInput |> Seq.distinctBy (fun x -> x.Range.StartLine) @@ -156,8 +156,6 @@ open CancellableTasks [)>] type internal FSharpBlockStructureService [] () = - let emptyValue = FSharpBlockStructure ImmutableArray.empty - interface IFSharpBlockStructureService with member _.GetBlockStructureAsync(document, cancellationToken) : Task = @@ -166,12 +164,33 @@ type internal FSharpBlockStructureService [] () = let! sourceText = document.GetTextAsync(cancellationToken) - let! parseResults = document.GetFSharpParseResultsAsync(nameof (FSharpBlockStructureService)) + let! parseResults = + cancellableTask { + // A cancellation of its own making - the checker relabels the internal ones with + // the caller's token - is no more an answer here than missing options are. + let! parseResults = + document.TryGetFSharpParseResultsAsync(nameof (FSharpBlockStructureService)) + |> CancellableTask.ifCanceledReturn ValueNone + + match parseResults with + | ValueSome parseResults -> return parseResults + | ValueNone -> + // Outlining only needs a parse; while the project has no options (loading or + // reloading) parse with the quick ones rather than collapse every region. + return! + document + .GetFSharpChecker() + .ParseFile( + document.FilePath, + sourceText.ToFSharpSourceText(), + document.GetFSharpQuickParsingOptions(), + userOpName = nameof (FSharpBlockStructureService) + ) + } return createBlockSpans document.Project.IsFSharpBlockStructureEnabled sourceText parseResults.ParseTree |> Seq.toImmutableArray |> FSharpBlockStructure } - |> CancellableTask.ifCanceledReturn emptyValue |> CancellableTask.start cancellationToken diff --git a/vsintegration/src/FSharp.Editor/VSPackage.resx b/vsintegration/src/FSharp.Editor/VSPackage.resx new file mode 100644 index 00000000000..948a9736142 --- /dev/null +++ b/vsintegration/src/FSharp.Editor/VSPackage.resx @@ -0,0 +1,168 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + + + F# + + + + F# Source File + + + + F# Tools + + + + F# Interactive + + + + IntelliSense + + + + QuickInfo + + + + Code Fixes + + + + Performance + + + + Advanced + + + + Formatting + + diff --git a/vsintegration/src/FSharp.Editor/xlf/VSPackage.cs.xlf b/vsintegration/src/FSharp.Editor/xlf/VSPackage.cs.xlf new file mode 100644 index 00000000000..af101d62e2a --- /dev/null +++ b/vsintegration/src/FSharp.Editor/xlf/VSPackage.cs.xlf @@ -0,0 +1,57 @@ + + + + + + F# + F# + + + + F# Source File + Zdrojový soubor F# + + + + F# Tools + Nástroje F# + + + + F# Interactive + F# Interactive + + + + IntelliSense + IntelliSense + + + + QuickInfo + Stručné informace + + + + Code Fixes + Opravy kódu + + + + Performance + Výkon + + + + Advanced + Upřesnit + + + + Formatting + Formátování + + + + + \ No newline at end of file diff --git a/vsintegration/src/FSharp.Editor/xlf/VSPackage.de.xlf b/vsintegration/src/FSharp.Editor/xlf/VSPackage.de.xlf new file mode 100644 index 00000000000..b7589dc7b7a --- /dev/null +++ b/vsintegration/src/FSharp.Editor/xlf/VSPackage.de.xlf @@ -0,0 +1,57 @@ + + + + + + F# + F# + + + + F# Source File + F#-Quelldatei + + + + F# Tools + F# Tools + + + + F# Interactive + F# Interactive + + + + IntelliSense + IntelliSense + + + + QuickInfo + QuickInfo + + + + Code Fixes + Codefehlerbehebungen + + + + Performance + Leistung + + + + Advanced + Erweitert + + + + Formatting + Formatierung + + + + + \ No newline at end of file diff --git a/vsintegration/src/FSharp.Editor/xlf/VSPackage.es.xlf b/vsintegration/src/FSharp.Editor/xlf/VSPackage.es.xlf new file mode 100644 index 00000000000..22371b3720a --- /dev/null +++ b/vsintegration/src/FSharp.Editor/xlf/VSPackage.es.xlf @@ -0,0 +1,57 @@ + + + + + + F# + F# + + + + F# Source File + Archivo de código fuente de F# + + + + F# Tools + Herramientas de F# + + + + F# Interactive + F# interactivo + + + + IntelliSense + IntelliSense + + + + QuickInfo + QuickInfo + + + + Code Fixes + Correcciones de código + + + + Performance + Rendimiento + + + + Advanced + Opciones avanzadas + + + + Formatting + Formato + + + + + \ No newline at end of file diff --git a/vsintegration/src/FSharp.Editor/xlf/VSPackage.fr.xlf b/vsintegration/src/FSharp.Editor/xlf/VSPackage.fr.xlf new file mode 100644 index 00000000000..a5535757dc1 --- /dev/null +++ b/vsintegration/src/FSharp.Editor/xlf/VSPackage.fr.xlf @@ -0,0 +1,57 @@ + + + + + + F# + F# + + + + F# Source File + Fichier source F# + + + + F# Tools + Outils F# + + + + F# Interactive + F# Interactive + + + + IntelliSense + IntelliSense + + + + QuickInfo + QuickInfo + + + + Code Fixes + Correctifs du code + + + + Performance + Performances + + + + Advanced + Avancé + + + + Formatting + Mise en forme + + + + + \ No newline at end of file diff --git a/vsintegration/src/FSharp.Editor/xlf/VSPackage.it.xlf b/vsintegration/src/FSharp.Editor/xlf/VSPackage.it.xlf new file mode 100644 index 00000000000..254fdfb2e9d --- /dev/null +++ b/vsintegration/src/FSharp.Editor/xlf/VSPackage.it.xlf @@ -0,0 +1,57 @@ + + + + + + F# + F# + + + + F# Source File + File di codice sorgente F# + + + + F# Tools + Strumenti F# + + + + F# Interactive + F# Interactive + + + + IntelliSense + IntelliSense + + + + QuickInfo + Informazioni rapide + + + + Code Fixes + Correzioni del codice + + + + Performance + Prestazioni + + + + Advanced + Avanzate + + + + Formatting + Formattazione + + + + + \ No newline at end of file diff --git a/vsintegration/src/FSharp.Editor/xlf/VSPackage.ja.xlf b/vsintegration/src/FSharp.Editor/xlf/VSPackage.ja.xlf new file mode 100644 index 00000000000..12c4fb59bc0 --- /dev/null +++ b/vsintegration/src/FSharp.Editor/xlf/VSPackage.ja.xlf @@ -0,0 +1,57 @@ + + + + + + F# + F# + + + + F# Source File + F# ソース ファイル + + + + F# Tools + F# ツール + + + + F# Interactive + F# インタラクティブ + + + + IntelliSense + IntelliSense + + + + QuickInfo + QuickInfo + + + + Code Fixes + コード修正 + + + + Performance + パフォーマンス + + + + Advanced + 詳細 + + + + Formatting + 書式設定 + + + + + \ No newline at end of file diff --git a/vsintegration/src/FSharp.Editor/xlf/VSPackage.ko.xlf b/vsintegration/src/FSharp.Editor/xlf/VSPackage.ko.xlf new file mode 100644 index 00000000000..8d47b988a4e --- /dev/null +++ b/vsintegration/src/FSharp.Editor/xlf/VSPackage.ko.xlf @@ -0,0 +1,57 @@ + + + + + + F# + F# + + + + F# Source File + F# 소스 파일 + + + + F# Tools + F# 도구 + + + + F# Interactive + F# 대화형 + + + + IntelliSense + IntelliSense + + + + QuickInfo + QuickInfo + + + + Code Fixes + 코드 수정 사항 + + + + Performance + 성능 + + + + Advanced + 고급 + + + + Formatting + 서식 + + + + + \ No newline at end of file diff --git a/vsintegration/src/FSharp.Editor/xlf/VSPackage.pl.xlf b/vsintegration/src/FSharp.Editor/xlf/VSPackage.pl.xlf new file mode 100644 index 00000000000..2b780cc9bc5 --- /dev/null +++ b/vsintegration/src/FSharp.Editor/xlf/VSPackage.pl.xlf @@ -0,0 +1,57 @@ + + + + + + F# + F# + + + + F# Source File + Plik źródłowy języka F# + + + + F# Tools + Narzędzia języka F# + + + + F# Interactive + F# Interactive + + + + IntelliSense + IntelliSense + + + + QuickInfo + QuickInfo + + + + Code Fixes + Poprawki kodu + + + + Performance + Wydajność + + + + Advanced + Zaawansowane + + + + Formatting + Formatowanie + + + + + \ No newline at end of file diff --git a/vsintegration/src/FSharp.Editor/xlf/VSPackage.pt-BR.xlf b/vsintegration/src/FSharp.Editor/xlf/VSPackage.pt-BR.xlf new file mode 100644 index 00000000000..b7ccf25e4b2 --- /dev/null +++ b/vsintegration/src/FSharp.Editor/xlf/VSPackage.pt-BR.xlf @@ -0,0 +1,57 @@ + + + + + + F# + F# + + + + F# Source File + Arquivo Fonte F# + + + + F# Tools + Ferramentas F# + + + + F# Interactive + F# Interativo + + + + IntelliSense + IntelliSense + + + + QuickInfo + QuickInfo + + + + Code Fixes + Correções de Código + + + + Performance + Desempenho + + + + Advanced + Avançado + + + + Formatting + Formatação + + + + + \ No newline at end of file diff --git a/vsintegration/src/FSharp.Editor/xlf/VSPackage.ru.xlf b/vsintegration/src/FSharp.Editor/xlf/VSPackage.ru.xlf new file mode 100644 index 00000000000..58eb92af51d --- /dev/null +++ b/vsintegration/src/FSharp.Editor/xlf/VSPackage.ru.xlf @@ -0,0 +1,57 @@ + + + + + + F# + F# + + + + F# Source File + Исходный файл F# + + + + F# Tools + Инструменты F# + + + + F# Interactive + F# Interactive + + + + IntelliSense + IntelliSense + + + + QuickInfo + QuickInfo + + + + Code Fixes + Исправления кода + + + + Performance + Производительность + + + + Advanced + Дополнительный + + + + Formatting + Форматирование + + + + + \ No newline at end of file diff --git a/vsintegration/src/FSharp.Editor/xlf/VSPackage.tr.xlf b/vsintegration/src/FSharp.Editor/xlf/VSPackage.tr.xlf new file mode 100644 index 00000000000..fe28afe03d2 --- /dev/null +++ b/vsintegration/src/FSharp.Editor/xlf/VSPackage.tr.xlf @@ -0,0 +1,57 @@ + + + + + + F# + F# + + + + F# Source File + F# Kaynak Dosyası + + + + F# Tools + F# Araçları + + + + F# Interactive + F# Etkileşimli + + + + IntelliSense + IntelliSense + + + + QuickInfo + QuickInfo + + + + Code Fixes + Kod Düzeltmeleri + + + + Performance + Performans + + + + Advanced + Gelişmiş + + + + Formatting + Biçimlendirme + + + + + \ No newline at end of file diff --git a/vsintegration/src/FSharp.Editor/xlf/VSPackage.zh-Hans.xlf b/vsintegration/src/FSharp.Editor/xlf/VSPackage.zh-Hans.xlf new file mode 100644 index 00000000000..7f89b326fec --- /dev/null +++ b/vsintegration/src/FSharp.Editor/xlf/VSPackage.zh-Hans.xlf @@ -0,0 +1,57 @@ + + + + + + F# + F# + + + + F# Source File + F# 源文件 + + + + F# Tools + F# 工具 + + + + F# Interactive + F# 交互窗口 + + + + IntelliSense + IntelliSense + + + + QuickInfo + QuickInfo + + + + Code Fixes + 代码修补程序 + + + + Performance + 性能 + + + + Advanced + 高级 + + + + Formatting + 格式设置 + + + + + \ No newline at end of file diff --git a/vsintegration/src/FSharp.Editor/xlf/VSPackage.zh-Hant.xlf b/vsintegration/src/FSharp.Editor/xlf/VSPackage.zh-Hant.xlf new file mode 100644 index 00000000000..cab17af78bd --- /dev/null +++ b/vsintegration/src/FSharp.Editor/xlf/VSPackage.zh-Hant.xlf @@ -0,0 +1,57 @@ + + + + + + F# + F# + + + + F# Source File + F# 原始程式檔 + + + + F# Tools + F# 工具 + + + + F# Interactive + F# 互動 + + + + IntelliSense + IntelliSense + + + + QuickInfo + QuickInfo + + + + Code Fixes + 程式碼修正 + + + + Performance + 效能 + + + + Advanced + 進階 + + + + Formatting + 格式化 + + + + + \ No newline at end of file diff --git a/vsintegration/src/FSharp.ProjectSystem.FSharp/Project.fs b/vsintegration/src/FSharp.ProjectSystem.FSharp/Project.fs index 701e086705d..50e9a1e5715 100644 --- a/vsintegration/src/FSharp.ProjectSystem.FSharp/Project.fs +++ b/vsintegration/src/FSharp.ProjectSystem.FSharp/Project.fs @@ -1388,7 +1388,7 @@ namespace rec Microsoft.VisualStudio.FSharp.ProjectSystem member _.CompilationReferences = x.CompilationReferences member _.CompilationBinOutputPath = let outputPath = x.GetCurrentOutputAssembly() - if String.IsNullOrWhiteSpace(outputPath) then None else Some(outputPath) + if String.IsNullOrWhiteSpace(outputPath) then ValueNone else ValueSome(outputPath) member _.Description = match sourcesAndFlags with @@ -1431,7 +1431,7 @@ namespace rec Microsoft.VisualStudio.FSharp.ProjectSystem member _.CompilationSourceFiles = sourceFiles member _.CompilationOptions = options member _.CompilationReferences = refs - member _.CompilationBinOutputPath = if String.IsNullOrWhiteSpace(outputPath) then None else Some(outputPath) + member _.CompilationBinOutputPath = if String.IsNullOrWhiteSpace(outputPath) then ValueNone else ValueSome(outputPath) member _.ProjectFileName = projFileName member _.BuildErrorReporter with get() = staticBuildErrorReporter diff --git a/vsintegration/tests/FSharp.Editor.Tests/BlockStructureServiceTests.fs b/vsintegration/tests/FSharp.Editor.Tests/BlockStructureServiceTests.fs new file mode 100644 index 00000000000..82ebc297b6a --- /dev/null +++ b/vsintegration/tests/FSharp.Editor.Tests/BlockStructureServiceTests.fs @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information. + +namespace FSharp.Editor.Tests + +open System.Threading +open Xunit +open Microsoft.CodeAnalysis +open Microsoft.CodeAnalysis.ExternalAccess.FSharp.Structure +open Microsoft.VisualStudio.FSharp.Editor +open FSharp.Editor.Tests.Helpers + +type BlockStructureServiceTests() = + + let getBlockStructure (document: Document) = + (FSharpBlockStructureService() :> IFSharpBlockStructureService) + .GetBlockStructureAsync(document, CancellationToken.None) + .GetAwaiter() + .GetResult() + + // Outlining needs only a parse, so a project without options yet (Visual Studio still loading it) + // must not answer with no regions and collapse everything the user had folded. + [] + member _.``Block structure falls back to quick parsing options when project options are unavailable``() = + let projectId = ProjectId.CreateNewId() + + let documentInfo = + RoslynTestHelpers.CreateDocumentInfo projectId "test.fs" "module M\n\nlet f x =\n let y = x + 1\n y * 2\n" + + let projectInfo = + RoslynTestHelpers.CreateProjectInfo projectId "test.fsproj" [ documentInfo ] + + let solution = RoslynTestHelpers.CreateSolution [ projectInfo ] + let document = RoslynTestHelpers.GetSingleDocument solution + + let structure = getBlockStructure document + + Assert.NotEmpty structure.Spans diff --git a/vsintegration/tests/FSharp.Editor.Tests/CopilotContextProviderTests.fs b/vsintegration/tests/FSharp.Editor.Tests/CopilotContextProviderTests.fs new file mode 100644 index 00000000000..9f98a83ceac --- /dev/null +++ b/vsintegration/tests/FSharp.Editor.Tests/CopilotContextProviderTests.fs @@ -0,0 +1,250 @@ +// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information. + +namespace FSharp.Editor.Tests + +open System +open System.Threading + +open Xunit + +open Microsoft.CodeAnalysis +open Microsoft.VisualStudio.Copilot +open Microsoft.VisualStudio.FSharp.Editor + +open FSharp.Editor.Tests.Helpers +open CancellableTasks + +module CopilotContextProviderTests = + + let fileContents = + """ +module Widgets + +/// Counts things that matter. +type Counter(start: int) = + let mutable value = start + + member _.Value = value + + member _.Bump() = + value <- value + 1 + value + +type Shape = + | Circle of radius: float + | Square of side: float + +let describeShape shape = + match shape with + | Circle r -> $"circle {r}" + | Square s -> $"square {s}" + +/// Twice the value. +let twice x = x * 2 +""" + + let solution = RoslynTestHelpers.CreateSolution fileContents + + let private cache = + MefHelpers.createExportProvider().GetExportedValue() + + let private run computation = + computation |> CancellableTask.start CancellationToken.None |> _.Result + + let private namesOf hits = + hits + |> Array.map (fun (struct (item, _)) -> CopilotSymbolMapping.fullyQualifiedName item) + + let private searchIn cache openDocumentIds solution pattern = + CopilotSymbolQuery.search cache openDocumentIds solution [| pattern |] + |> run + |> Array.head + |> namesOf + + let private search pattern = + searchIn cache Seq.empty solution pattern + + let private symbolContext name = + CopilotSymbolQuery.symbolContext cache Seq.empty solution name |> run + + let private contextOf name = + match symbolContext name with + | ValueSome context -> context + | ValueNone -> failwith $"expected a symbol context for {name}" + + [] + [] + [] + [] + [] + let ``search finds a declaration by its fully qualified name`` (pattern: string, expected: string) = + Assert.Contains(expected, search pattern) + + [] + [] + [] + [] + [] + [] + [] + let ``a name matches only the declaration it spells out`` (candidate: string, expected: bool) = + let item = + CopilotSymbolQuery.search cache Seq.empty solution [| "Counter" |] + |> run + |> Array.head + |> Array.pick (fun (struct (item, _)) -> + if CopilotSymbolMapping.fullyQualifiedName item = "Widgets.Counter" then + Some item + else + None) + + Assert.Equal(expected, CopilotSymbolMapping.hasFullyQualifiedName candidate item) + + [] + let ``search reports each declaration once`` () = + let names = search "Counter" + Assert.Equal((Array.distinct names).Length, names.Length) + + [] + let ``an unknown name has no context`` () = + Assert.True((symbolContext "Widgets.NoSuchThing").IsNone) + + /// One matcher and one parse cache per test, so what a test leaves parsed cannot answer the next one. + let private freshCache () = + MefHelpers.createExportProvider().GetExportedValue() + + let private solutionOf files = + let projectId = ProjectId.CreateNewId() + + let documents = + files + |> List.map (fun (path, source) -> RoslynTestHelpers.CreateDocumentInfo projectId path source) + + let solution = + RoslynTestHelpers.CreateSolution [ RoslynTestHelpers.CreateProjectInfo projectId "C:\\many.fsproj" documents ] + + { RoslynTestHelpers.DefaultProjectOptions with + SourceFiles = files |> List.map fst |> Array.ofList + } + |> RoslynTestHelpers.SetProjectOptions projectId solution + + solution + + let private documentsOf (solution: Solution) = + solution.Projects |> Seq.exactlyOne |> _.Documents |> Seq.toArray + + let private documentNamed (name: string) solution = + documentsOf solution + |> Array.find _.FilePath.EndsWith(name, StringComparison.Ordinal) + + /// A file holding more declarations matching `name` than one query reports. + let private manyDeclarations name count = + let members = + [ for i in 1..count -> $" member _.{name}{i} = {i}" ] |> String.concat "\n" + + $"module {name}Module\n\ntype {name}Holder() =\n{members}\n" + + let private coldFile = "C:\\cold.fs", "module Cold\n\nlet widgetCounter = 1\n" + + [] + let ``an open document answers without parsing the rest of the solution`` () = + let cache = freshCache () + let solution = solutionOf [ "C:\\open.fs", manyDeclarations "Widget" 25; coldFile ] + let opened = documentNamed "open.fs" solution + + let names = searchIn cache [ opened.Id ] solution "Widget" + + Assert.Equal(20, names.Length) + Assert.All(names, fun name -> Assert.StartsWith("WidgetModule", name, StringComparison.Ordinal)) + Assert.True((cache.TryGetCachedNavigableItems (documentNamed "cold.fs" solution).Id).IsNone) + + [] + let ``documents already parsed answer without parsing the rest`` () = + let cache = freshCache () + let solution = solutionOf [ "C:\\warm.fs", manyDeclarations "Widget" 25; coldFile ] + cache.GetNavigableItems(documentNamed "warm.fs" solution) |> run |> ignore + + let names = searchIn cache Seq.empty solution "Widget" + + Assert.Equal(20, names.Length) + Assert.True((cache.TryGetCachedNavigableItems (documentNamed "cold.fs" solution).Id).IsNone) + + [] + let ``the search stops once it has enough declarations`` () = + let cache = freshCache () + + let solution = + solutionOf + [ + for i in 1..150 -> $"C:\\cold{i}.fs", $"module Cold{i}\n\ntype Counter{i}() =\n member _.Value = {i}\n" + ] + + let names = searchIn cache Seq.empty solution "Counter" + + Assert.Equal(20, names.Length) + + Assert.NotEmpty( + documentsOf solution + |> Array.filter (fun document -> (cache.TryGetCachedNavigableItems document.Id).IsNone) + ) + + [] + let ``a batch of texts answers like the same texts one by one`` () = + let cache = freshCache () + + let solution = + solutionOf + [ + "C:\\a.fs", "module A\n\nlet alpha = 1\n" + "C:\\b.fs", "module B\n\nlet beta = 2\n" + ] + + let batched = + CopilotSymbolQuery.search cache Seq.empty solution [| "alpha"; "beta" |] + |> run + |> Array.map namesOf + + Assert.Equal(searchIn cache Seq.empty solution "alpha", batched[0]) + Assert.Equal(searchIn cache Seq.empty solution "beta", batched[1]) + + [] + let ``a type context carries the whole declaration and its doc comment`` () = + let context = contextOf "Widgets.Counter" + + Assert.Equal("Widgets.Counter", context.FullyQualifiedName) + Assert.Equal("Counter", context.UnqualifiedName) + Assert.Contains("Counts things that matter.", context.Snippet) + Assert.Contains("member _.Bump()", context.Snippet) + + [] + let ``a member context carries the member body alone`` () = + let context = contextOf "Widgets.Counter.Bump" + + Assert.Contains("value <- value + 1", context.Snippet) + Assert.DoesNotContain("type Counter", context.Snippet) + + [] + let ``a one-line declaration keeps its doc comment`` () = + let context = contextOf "Widgets.twice" + + Assert.Contains("Twice the value.", context.Snippet) + Assert.Contains("let twice x", context.Snippet) + Assert.DoesNotContain("describeShape", context.Snippet) + + [] + [] + [] + [] + [] + [] + let ``declaration kinds map onto Copilot symbol types`` (name: string, expected: CopilotSymbolContextType) = + Assert.Equal(expected, (contextOf name).SymbolType) + + [] + let ``a context points back at the source it was taken from`` () = + let context = contextOf "Widgets.Counter" + let location = Assert.Single context.SnippetLocations + let document = solution.Projects |> Seq.exactlyOne |> _.Documents |> Seq.exactlyOne + + Assert.Equal(document.FilePath, location.FilePath) + Assert.Equal(context.Snippet.Length, location.Span.Length) diff --git a/vsintegration/tests/FSharp.Editor.Tests/CrossLanguageSymbolNavigationTests.fs b/vsintegration/tests/FSharp.Editor.Tests/CrossLanguageSymbolNavigationTests.fs new file mode 100644 index 00000000000..a8aa600540b --- /dev/null +++ b/vsintegration/tests/FSharp.Editor.Tests/CrossLanguageSymbolNavigationTests.fs @@ -0,0 +1,186 @@ +// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information. + +module FSharp.Editor.Tests.CrossLanguageSymbolNavigationTests + +open System +open System.Threading +open Xunit +open Microsoft.CodeAnalysis +open Microsoft.VisualStudio.FSharp.Editor +open Microsoft.VisualStudio.FSharp.Editor.CancellableTasks +open FSharp.Compiler.EditorServices +open FSharp.Compiler.Text +open FSharp.Editor.Tests.Helpers +open FSharp.Test.ProjectGeneration + +let private source = + """ +module Widgets + +type Counter(start: int) = + member val Value = start with get, set + member this.Bump() = this.Value <- this.Value + 1 + +let twice x = x * 2 + +[] +let thrice x = x * 3 + +type Shape = + | Circle of radius: float + | Square of side: float + | Dot + +[] +module Shape = + let area shape = + match shape with + | Circle r -> Math.PI * r * r + | Square s -> s * s + | Dot -> 0.0 + +[] +let Answer = 42 + +type Box<'T> = { Value: 'T } + +type Point = { X: int; Y: int } + +exception MyError of string +""" + +let private document = RoslynTestHelpers.GetFsDocument source +let private project = document.Project + +let private cache = + MefHelpers.createExportProvider().GetExportedValue() + +let private run computation = + computation |> CancellableTask.start CancellationToken.None |> _.Result + +/// The 1-based line of the first source line containing the text. +let private lineOf (text: string) = + source.Split('\n') + |> Array.findIndex (fun line -> line.IndexOf(text, StringComparison.Ordinal) >= 0) + |> (+) 1 + +let private items = cache.GetNavigableItems document |> run + +[] +[] +[] +[] +[] +[] +[] +[] +[] +[] +[] +[] +[] +[] +[] +[] +[] +[] +let ``the fast path finds the declaration and agrees with the whole project check`` (docId: string, declaration: string) = + let path = CrossLanguageSymbolNavigation.docCommentIdToPath docId + + let fast = + CrossLanguageSymbolNavigation.tryLocateViaNavigableItems cache docId path project + |> run + + let full = + CrossLanguageSymbolNavigation.tryLocateInProject docId path project |> run + + match fast, full with + | ValueSome fast, ValueSome full -> + Assert.Equal(full, fast) + Assert.Equal(document.FilePath, fast.FileName) + Assert.Equal(lineOf declaration, fast.StartLine) + | fast, full -> failwith $"fast path: %A{fast}, whole project: %A{full}" + +[] +[] +[] +[] +let ``an unknown or malformed id yields no location`` (docId: string) = + let found = + CrossLanguageSymbolNavigation.tryFindDeclaration cache project.Solution project.AssemblyName docId + |> run + + Assert.True(found.IsNone, $"%A{found}") + +[] +[] +[] +[] +[] +[] +let ``the parsed declarations of an entity are recognised through compiled-name artifacts`` (entityPath: string, declaration: string) = + let declaring = + items + |> Array.filter (CrossLanguageSymbolNavigation.declaresEntity (List.ofArray (entityPath.Split '.'))) + |> Array.map _.Name + + Assert.Equal([ declaration ], List.ofArray declaring |> List.distinct) + +[] +let ``members and unknown entities never pass as declarations`` () = + for entityPath in [ [ "Widgets"; "twice" ]; [ "Widgets"; "Counter"; "Bump" ]; [ "Nope" ] ] do + Assert.Empty(items |> Array.filter (CrossLanguageSymbolNavigation.declaresEntity entityPath)) + +[] +let ``candidate documents come in compile order, signature first`` () = + let syntheticProject = + SyntheticProject.Create( + sourceFile "First" [], + { sourceFile "Second" [] with + SignatureFile = AutoGenerated + }, + sourceFile "Third" [ "Second" ] + ) + + let solution, _ = RoslynTestHelpers.CreateSolution syntheticProject + let project = solution.Projects |> Seq.exactlyOne + + let candidates = + CrossLanguageSymbolNavigation.candidateDocuments cache [ syntheticProject.Name; "ModuleSecond" ] project + |> run + |> List.map _.FilePath + + Assert.Equal( + [ + syntheticProject.GetSignatureFilePath "Second" + syntheticProject.GetFilePath "Second" + ], + candidates + ) + +[] +let ``the first instance of a multi-targeted project answers`` () = + let instance () = + let id = ProjectId.CreateNewId() + + id, RoslynTestHelpers.CreateProjectInfo id "C:\\test.fsproj" [ RoslynTestHelpers.CreateDocumentInfo id "C:\\test.fs" source ] + + let firstId, first = instance () + let secondId, second = instance () + let solution = RoslynTestHelpers.CreateSolution [ first; second ] + + let options = + { RoslynTestHelpers.DefaultProjectOptions with + OtherOptions = [| "--targetprofile:netcore"; "--nowarn:3384" |] + } + + for id in [ firstId; secondId ] do + RoslynTestHelpers.SetProjectOptions id solution options + + let found = + CrossLanguageSymbolNavigation.tryFindDeclaration cache solution "test.dll" "M:Widgets.twice(System.Int32)" + |> run + + match found with + | ValueSome(struct (_, project)) -> Assert.Equal(firstId, project.Id) + | ValueNone -> failwith "declaration not found" diff --git a/vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj b/vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj index ecce1205b8c..b744e41554d 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj +++ b/vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj @@ -27,11 +27,18 @@ + + + + + + + @@ -82,7 +89,9 @@ + + @@ -94,6 +103,7 @@ + diff --git a/vsintegration/tests/FSharp.Editor.Tests/FileChangeWatcherTests.fs b/vsintegration/tests/FSharp.Editor.Tests/FileChangeWatcherTests.fs new file mode 100644 index 00000000000..269f5476603 --- /dev/null +++ b/vsintegration/tests/FSharp.Editor.Tests/FileChangeWatcherTests.fs @@ -0,0 +1,360 @@ +// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information. + +namespace FSharp.Editor.Tests + +open System +open System.Collections.Immutable +open System.IO +open System.Threading +open System.Threading.Tasks +open Xunit +open Microsoft.VisualStudio.Shell +open Microsoft.VisualStudio.FSharp.Editor + +type private MockFileChangeContext() = + let fileChanged = Event() + let watched = ResizeArray() + + member _.WatchedFiles = List.ofSeq watched + member _.Fire path = fileChanged.Trigger path + + interface IFSharpFileChangeContext with + [] + member _.FileChanged = fileChanged.Publish + + member _.EnqueueWatchingFile path = + watched.Add path + + { new IFSharpWatchedFile with + member _.Dispose() = watched.Remove path |> ignore + } + + member _.Dispose() = watched.Clear() + +type private MockFileChangeWatcher() = + let mutable context: MockFileChangeContext voption = ValueNone + + member _.Context = context + + interface IFSharpFileChangeWatcher with + member _.CreateContext _ = + let ctx = new MockFileChangeContext() + context <- ValueSome ctx + ctx :> IFSharpFileChangeContext + +type private ServiceCall = + | AdvisedDir of path: string * cookie: uint32 + | FilteredDir of cookie: uint32 * extensions: string list + | AdvisedFiles of paths: string list * sink: obj * cookies: uint32 list + | UnadvisedFiles of cookies: uint32 list + | UnadvisedDirs of cookies: uint32 list + +/// +/// Stands in for : hands out sequential cookies and records every call, so a +/// test can see how the watcher turned its queue into service calls. +/// +type private RecordingFileChangeService() = + let calls = ResizeArray() + let mutable nextCookie = 0u + + let record call = lock calls (fun () -> calls.Add call) + + let newCookie () = + nextCookie <- nextCookie + 1u + nextCookie + + member _.Calls = lock calls (fun () -> List.ofSeq calls) + + member this.WaitForCalls(count: int) = + let deadline = DateTime.UtcNow + TimeSpan.FromSeconds 10. + + while lock calls (fun () -> calls.Count) < count && DateTime.UtcNow < deadline do + Thread.Sleep 10 + + this.Calls + + interface IVsAsyncFileChangeEx2 with + member _.AdviseFileChangesAsync(filenames, _, sink, _) = + let cookies = [| for _ in filenames -> newCookie () |] + record (AdvisedFiles(List.ofSeq filenames, box sink, List.ofArray cookies)) + Task.FromResult cookies + + interface IVsAsyncFileChangeEx with + member _.AdviseFileChangeAsync(_, _, _, _) = Task.FromResult(newCookie ()) + member _.UnadviseFileChangeAsync(_, _) = Task.FromResult "" + + member _.UnadviseFileChangesAsync(cookies, _) = + record (UnadvisedFiles(List.ofSeq cookies)) + Task.FromResult Array.empty + + member _.AdviseDirChangeAsync(directory, _, _, _) = + let cookie = newCookie () + record (AdvisedDir(directory, cookie)) + Task.FromResult cookie + + member _.UnadviseDirChangeAsync(_, _) = Task.FromResult "" + + member _.UnadviseDirChangesAsync(cookies, _) = + record (UnadvisedDirs(List.ofSeq cookies)) + Task.FromResult Array.empty + + member _.SyncFileAsync(_, _) = Task.CompletedTask + member _.IgnoreFileAsync(_, _, _, _) = Task.CompletedTask + member _.IgnoreDirAsync(_, _, _) = Task.CompletedTask + + member _.FilterDirectoryChangesAsync(cookie, extensions, _) = + record (FilteredDir(cookie, List.ofArray extensions)) + Task.CompletedTask + +module FileChangeWatcherTests = + + let private testDelay = TimeSpan.FromMilliseconds 50. + + let private batchDelay = TimeSpan.FromMilliseconds 100. + + let private noDirectories = ImmutableArray.Empty + + let private createWatcher (service: RecordingFileChangeService) = + new FSharpFileChangeWatcher(Task.FromResult(service :> IVsAsyncFileChangeEx2), batchDelay) + + [] + let ``WatchedDirectory covers files under it matching the extension filter`` () = + let dirs = + ImmutableArray.Create(WatchedDirectory(@"C:\refs", ImmutableArray.Create ".dll")) + + Assert.True(WatchedDirectory.FilePathCoveredByWatchedDirectories(dirs, @"C:\refs\sub\a.dll")) + Assert.True(WatchedDirectory.FilePathCoveredByWatchedDirectories(dirs, @"C:\REFS\A.DLL")) + Assert.False(WatchedDirectory.FilePathCoveredByWatchedDirectories(dirs, @"C:\refs\a.xml")) + Assert.False(WatchedDirectory.FilePathCoveredByWatchedDirectories(dirs, @"C:\other\a.dll")) + + [] + let ``WatchedDirectory without filters covers any file under it`` () = + let dirs = + ImmutableArray.Create(WatchedDirectory(@"C:\refs", ImmutableArray.Empty)) + + Assert.True(WatchedDirectory.FilePathCoveredByWatchedDirectories(dirs, @"C:\refs\a.xml")) + Assert.False(WatchedDirectory.FilePathCoveredByWatchedDirectories(dirs, @"C:\refsx\a.xml")) + + [] + let ``Tracker ref-counts subscriptions per path`` () = + let watcher = MockFileChangeWatcher() + use tracker = new FSharpReferenceChangeTracker(watcher, ignore, testDelay) + + tracker.StartWatchingReference @"C:\x\a.dll" + tracker.StartWatchingReference @"C:\x\a.dll" + tracker.StartWatchingReference @"C:\x\b.dll" + + Assert.Equal([ @"C:\x\a.dll"; @"C:\x\b.dll" ], watcher.Context.Value.WatchedFiles) + + tracker.StopWatchingReference @"C:\x\a.dll" + Assert.Contains(@"C:\x\a.dll", watcher.Context.Value.WatchedFiles) + + tracker.StopWatchingReference @"C:\x\a.dll" + Assert.Equal([ @"C:\x\b.dll" ], watcher.Context.Value.WatchedFiles) + + [] + let ``Tracker debounces bursts into a single callback for watched paths only`` () = + let watcher = MockFileChangeWatcher() + let calls = ResizeArray() + use signal = new ManualResetEventSlim(false) + + use tracker = + new FSharpReferenceChangeTracker( + watcher, + (fun path -> + lock calls (fun () -> calls.Add path) + signal.Set()), + testDelay + ) + + tracker.StartWatchingReference @"C:\x\a.dll" + let context = watcher.Context.Value + + context.Fire @"C:\x\a.dll" + context.Fire @"C:\x\a.dll" + context.Fire @"C:\x\unwatched.dll" + + Assert.True(signal.Wait(TimeSpan.FromSeconds 10.)) + // Allow a trailing duplicate timer to fire if one was pending. + Thread.Sleep(testDelay + testDelay) + + Assert.Equal([ @"C:\x\a.dll" ], lock calls (fun () -> List.ofSeq calls)) + + [] + let ``Disposed tracker ignores further changes`` () = + let watcher = MockFileChangeWatcher() + let mutable called = false + + let tracker = + new FSharpReferenceChangeTracker(watcher, (fun _ -> called <- true), testDelay) + + tracker.StartWatchingReference @"C:\x\a.dll" + let context = watcher.Context.Value + (tracker :> IDisposable).Dispose() + + context.Fire @"C:\x\a.dll" + Thread.Sleep(testDelay + testDelay) + + Assert.False called + + [] + let ``Consecutive file watches are advised in one service call`` () = + let service = RecordingFileChangeService() + use watcher = createWatcher service + use context = (watcher :> IFSharpFileChangeWatcher).CreateContext noDirectories + + for path in [ @"C:\x\a.dll"; @"C:\x\b.dll"; @"C:\x\c.dll" ] do + context.EnqueueWatchingFile path |> ignore + + match service.WaitForCalls 1 with + | [ AdvisedFiles(paths, _, cookies) ] -> + Assert.Equal([ @"C:\x\a.dll"; @"C:\x\b.dll"; @"C:\x\c.dll" ], paths) + Assert.Equal([ 1u; 2u; 3u ], cookies) + | calls -> failwith $"Unexpected calls: %A{calls}" + + [] + let ``A run of file watches is split when the sink changes`` () = + let service = RecordingFileChangeService() + use watcher = createWatcher service + let factory = watcher :> IFSharpFileChangeWatcher + use first = factory.CreateContext noDirectories + use second = factory.CreateContext noDirectories + + first.EnqueueWatchingFile @"C:\x\a.dll" |> ignore + first.EnqueueWatchingFile @"C:\x\b.dll" |> ignore + second.EnqueueWatchingFile @"C:\x\c.dll" |> ignore + + match service.WaitForCalls 2 with + | [ AdvisedFiles(firstPaths, firstSink, [ 1u; 2u ]); AdvisedFiles(secondPaths, secondSink, [ 3u ]) ] -> + Assert.Equal([ @"C:\x\a.dll"; @"C:\x\b.dll" ], firstPaths) + Assert.Equal([ @"C:\x\c.dll" ], secondPaths) + Assert.False(obj.ReferenceEquals(firstSink, secondSink)) + | calls -> failwith $"Unexpected calls: %A{calls}" + + [] + let ``A watch followed by an unwatch in the same batch unadvises the cookie the watch received`` () = + let service = RecordingFileChangeService() + use watcher = createWatcher service + use context = (watcher :> IFSharpFileChangeWatcher).CreateContext noDirectories + + let watched = context.EnqueueWatchingFile @"C:\x\a.dll" + watched.Dispose() + + match service.WaitForCalls 2 with + | [ AdvisedFiles(_, _, [ advised ]); UnadvisedFiles [ unadvised ] ] -> Assert.Equal(advised, unadvised) + | calls -> failwith $"Unexpected calls: %A{calls}" + + [] + let ``Unwatching a token that holds no cookie is a no-op`` () = + let service = RecordingFileChangeService() + use watcher = createWatcher service + use context = (watcher :> IFSharpFileChangeWatcher).CreateContext noDirectories + + let watched = context.EnqueueWatchingFile @"C:\x\a.dll" + service.WaitForCalls 1 |> ignore + + watched.Dispose() + service.WaitForCalls 2 |> ignore + + watched.Dispose() + Thread.Sleep(batchDelay + batchDelay + batchDelay) + + match service.Calls with + | [ AdvisedFiles(_, _, [ advised ]); UnadvisedFiles [ unadvised ] ] -> Assert.Equal(advised, unadvised) + | calls -> failwith $"Unexpected calls: %A{calls}" + + [] + let ``Disposing a context unadvises its directory and remaining file cookies`` () = + let service = RecordingFileChangeService() + use watcher = createWatcher service + + let directories = + ImmutableArray.Create(WatchedDirectory(@"C:\refs", ImmutableArray.Create ".dll")) + + let context = (watcher :> IFSharpFileChangeWatcher).CreateContext directories + context.EnqueueWatchingFile @"C:\refs\covered.dll" |> ignore + context.EnqueueWatchingFile @"C:\other\a.dll" |> ignore + service.WaitForCalls 3 |> ignore + + context.Dispose() + + match service.WaitForCalls 5 with + | [ AdvisedDir(directory, 1u) + FilteredDir(1u, [ ".dll" ]) + AdvisedFiles([ @"C:\other\a.dll" ], _, [ 2u ]) + UnadvisedDirs [ 1u ] + UnadvisedFiles [ 2u ] ] -> Assert.Equal(@"C:\refs\", directory) + | calls -> failwith $"Unexpected calls: %A{calls}" + + let private t0 = DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc) + let private t1 = t0.AddHours 1. + + let private withTempFile (test: string -> unit) = + let path = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid()}.dll") + File.WriteAllBytes(path, Array.empty) + File.SetLastWriteTimeUtc(path, t0) + + try + test path + finally + File.Delete path + + [] + let ``Watched path is served from the cache until a change notification`` () = + withTempFile (fun path -> + let watcher = MockFileChangeWatcher() + use tracker = new FSharpReferenceChangeTracker(watcher, ignore, testDelay) + let stamps = tracker :> IReferenceStamps + + tracker.StartWatchingReference path + Assert.Equal(t0, stamps.GetLastWriteTimeUtc path) + + File.SetLastWriteTimeUtc(path, t1) + Assert.Equal(t0, stamps.GetLastWriteTimeUtc path) + + watcher.Context.Value.Fire path + Assert.Equal(t1, stamps.GetLastWriteTimeUtc path)) + + [] + let ``Unwatched path is stat'd on every read`` () = + withTempFile (fun path -> + let watcher = MockFileChangeWatcher() + use tracker = new FSharpReferenceChangeTracker(watcher, ignore, testDelay) + let stamps = tracker :> IReferenceStamps + + Assert.Equal(t0, stamps.GetLastWriteTimeUtc path) + + File.SetLastWriteTimeUtc(path, t1) + Assert.Equal(t1, stamps.GetLastWriteTimeUtc path)) + + [] + let ``Invalidate drops the cached stamp`` () = + withTempFile (fun path -> + let watcher = MockFileChangeWatcher() + use tracker = new FSharpReferenceChangeTracker(watcher, ignore, testDelay) + let stamps = tracker :> IReferenceStamps + + tracker.StartWatchingReference path + Assert.Equal(t0, stamps.GetLastWriteTimeUtc path) + + File.SetLastWriteTimeUtc(path, t1) + stamps.Invalidate path + Assert.Equal(t1, stamps.GetLastWriteTimeUtc path)) + + [] + let ``Stopping the last watch on a path falls back to stat`` () = + withTempFile (fun path -> + let watcher = MockFileChangeWatcher() + use tracker = new FSharpReferenceChangeTracker(watcher, ignore, testDelay) + let stamps = tracker :> IReferenceStamps + + tracker.StartWatchingReference path + tracker.StartWatchingReference path + Assert.Equal(t0, stamps.GetLastWriteTimeUtc path) + + File.SetLastWriteTimeUtc(path, t1) + tracker.StopWatchingReference path + Assert.Equal(t0, stamps.GetLastWriteTimeUtc path) + + tracker.StopWatchingReference path + Assert.Equal(t1, stamps.GetLastWriteTimeUtc path)) diff --git a/vsintegration/tests/FSharp.Editor.Tests/FindReferencesFromCSharpTests.fs b/vsintegration/tests/FSharp.Editor.Tests/FindReferencesFromCSharpTests.fs new file mode 100644 index 00000000000..f678f649916 --- /dev/null +++ b/vsintegration/tests/FSharp.Editor.Tests/FindReferencesFromCSharpTests.fs @@ -0,0 +1,156 @@ +// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information. + +/// An F# library and a C# project referencing its built assembly, as VS wires a C# → F# project reference. +module FSharp.Editor.Tests.FindReferencesFromCSharpTests + +open System +open System.Collections.Immutable +open System.IO +open System.Reflection +open System.Threading +open Xunit +open Microsoft.CodeAnalysis +open Microsoft.CodeAnalysis.ExternalAccess.FSharp.Editor.FindUsages +open Microsoft.CodeAnalysis.ExternalAccess.FSharp.FindUsages +open Microsoft.CodeAnalysis.FindSymbols +open Microsoft.CodeAnalysis.Text +open Microsoft.VisualStudio.FSharp.Editor +open Microsoft.VisualStudio.FSharp.Editor.CancellableTasks +open FSharp.Editor.Tests.Helpers +open FSharp.Test.ProjectGeneration + +let private library = + SyntheticProject.Create( + { sourceFile "First" [] with + ExtraSource = "let twice x = x * 2\n[]\nlet answer = 42\n" + } + ) + +/// The synthetic project puts its modules in a namespace named after the project. +let private moduleName = $"{library.Name}.ModuleFirst" + +let private solution = + let librarySolution, checker = RoslynTestHelpers.CreateMultiProjectSolution library + let assembly = RoslynTestHelpers.CompileToAssembly(library, checker) + + RoslynTestHelpers.AddCSharpProject( + librarySolution, + "Consumer", + $"class Consumer {{ int M() => {moduleName}.twice(1); int N() => {moduleName}.answer; }}", + library.GetProjectOptions checker, + [ assembly ] + ) + +let private consumer = + solution.Projects |> Seq.find (fun p -> p.Language = LanguageNames.CSharp) + +let private firstPath = library.GetFilePath "First" + +let private declarationPosition = + (File.ReadAllText firstPath).IndexOf("twice", StringComparison.Ordinal) + +let private fsharpDocument = + solution.GetDocumentIdsWithFilePath firstPath + |> Seq.exactlyOne + |> solution.GetDocument + +let private findUsagesService = + FSharpFindUsagesService() :> IFSharpFindUsagesService + +/// ExternalAccess exposes no span on a reference item; its Roslyn item is read through reflection. +let private documentSpanOf (reference: FSharpSourceReferenceItem) = + let flags = BindingFlags.Instance ||| BindingFlags.NonPublic ||| BindingFlags.Public + + let property (target: obj) name = + target.GetType().GetProperty(name, flags).GetValue target + + let documentSpan = + property (property reference "RoslynSourceReferenceItem") "SourceSpan" + + property documentSpan "Document" :?> Document, property documentSpan "SourceSpan" :?> TextSpan + +[] +let ``the C# compilation resolves the doc comment id of an F# function`` () = + let compilation = consumer.GetCompilationAsync(CancellationToken.None).Result + + let errors = + compilation.GetDiagnostics() + |> Seq.filter (fun d -> d.Severity = DiagnosticSeverity.Error) + + Assert.Empty errors + + let twice = + DocumentationCommentId.GetFirstSymbolForDeclarationId($"M:{moduleName}.twice(System.Int32)", compilation) + + Assert.NotNull twice + + let references = + SymbolFinder.FindReferencesAsync(twice, solution, ImmutableHashSet.CreateRange consumer.Documents, CancellationToken.None).Result + + Assert.Single(references |> Seq.collect _.Locations) |> ignore + +[] +let ``the consumer is found as a project referencing the F# assembly`` () = + let referencing = + ProjectFiltering.getProjectsReferencingAssembly fsharpDocument.Project.OutputFilePath solution + + Assert.Equal([ consumer.Id ], referencing |> List.map _.Id) + +[] +[] +[] +[] +[] +let ``DocumentationCommentId is the compiled form Roslyn resolves`` (symbolName: string, expectedFormat: string) = + let expected = + expectedFormat + |> ValueOption.ofObj + |> ValueOption.map (fun format -> String.Format(format, moduleName)) + + let _, checkFileResults = + fsharpDocument.GetFSharpParseAndCheckResultsAsync "test" + |> CancellableTask.runSynchronouslyWithoutCancellation + + let symbol = + checkFileResults.GetAllUsesOfAllSymbolsInFile() + |> Seq.find (fun symbolUse -> symbolUse.IsFromDefinition && symbolUse.Symbol.DisplayName = symbolName) + |> _.Symbol + + Assert.Equal(expected, symbol.DocumentationCommentId) + +[] +let ``Find All References on an F# function reports its C# call site`` () = + let context, foundDefinitions, foundReferences = + RoslynTestHelpers.CreateFindUsagesContext() + + findUsagesService.FindReferencesAsync(fsharpDocument, declarationPosition, context).Wait() + + Assert.Equal(1, foundDefinitions.Count) + let document, span = documentSpanOf (Assert.Single foundReferences) + Assert.Equal(LanguageNames.CSharp, document.Project.Language) + + let text = document.GetTextAsync(CancellationToken.None).Result + Assert.Equal("twice", text.ToString span) + +[] +let ``Find All References on an F# literal reports its C# use`` () = + let context, _, foundReferences = RoslynTestHelpers.CreateFindUsagesContext() + + let position = + (File.ReadAllText firstPath).IndexOf("answer", StringComparison.Ordinal) + + findUsagesService.FindReferencesAsync(fsharpDocument, position, context).Wait() + + let document, span = documentSpanOf (Assert.Single foundReferences) + Assert.Equal(LanguageNames.CSharp, document.Project.Language) + + let text = document.GetTextAsync(CancellationToken.None).Result + Assert.Equal("answer", text.ToString span) + +[] +let ``Find Implementations on an F# function does not report C# call sites`` () = + let context, _, foundReferences = RoslynTestHelpers.CreateFindUsagesContext() + + findUsagesService.FindImplementationsAsync(fsharpDocument, declarationPosition, context).Wait() + + Assert.Empty foundReferences diff --git a/vsintegration/tests/FSharp.Editor.Tests/FindReferencesTests.fs b/vsintegration/tests/FSharp.Editor.Tests/FindReferencesTests.fs index 5519fdd337b..4d6755736d8 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/FindReferencesTests.fs +++ b/vsintegration/tests/FSharp.Editor.Tests/FindReferencesTests.fs @@ -1,12 +1,8 @@ module FSharp.Editor.Tests.FindReferencesTests -open System.Threading.Tasks -open System.Threading open System.IO -open System.Collections.Concurrent open Microsoft.CodeAnalysis.ExternalAccess.FSharp.Editor.FindUsages -open Microsoft.CodeAnalysis.ExternalAccess.FSharp.FindUsages open Microsoft.VisualStudio.FSharp.Editor open Xunit @@ -40,27 +36,7 @@ module FindReferences = let findUsagesService = FSharpFindUsagesService() :> IFSharpFindUsagesService let getContext () = - let foundDefinitions = ConcurrentBag() - let foundReferences = ConcurrentBag() - - let context = - { new IFSharpFindUsagesContext with - - member _.OnDefinitionFoundAsync(definition: FSharpDefinitionItem) = - foundDefinitions.Add definition - Task.CompletedTask - - member _.OnReferenceFoundAsync(reference: FSharpSourceReferenceItem) = - foundReferences.Add reference - Task.CompletedTask - - member _.ReportMessageAsync _ = Task.CompletedTask - member _.ReportProgressAsync(_, _) = Task.CompletedTask - member _.SetSearchTitleAsync _ = Task.CompletedTask - member _.CancellationToken = CancellationToken.None - } - - context, foundDefinitions, foundReferences + RoslynTestHelpers.CreateFindUsagesContext() [] let ``Find references to a document-local symbol`` () = diff --git a/vsintegration/tests/FSharp.Editor.Tests/GoToDefinitionServiceTests.fs b/vsintegration/tests/FSharp.Editor.Tests/GoToDefinitionServiceTests.fs index fe10a42a125..68d8755ac89 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/GoToDefinitionServiceTests.fs +++ b/vsintegration/tests/FSharp.Editor.Tests/GoToDefinitionServiceTests.fs @@ -2,6 +2,8 @@ namespace FSharp.Editor.Tests +open System +open System.Threading open Xunit open Microsoft.CodeAnalysis open Microsoft.CodeAnalysis.Text @@ -9,8 +11,8 @@ open Microsoft.VisualStudio.FSharp.Editor open FSharp.Compiler.EditorServices open FSharp.Compiler.Text open FSharp.Editor.Tests.Helpers +open FSharp.Test.ProjectGeneration open Microsoft.VisualStudio.FSharp.Editor.CancellableTasks -open System.Threading module GoToDefinitionServiceTests = @@ -149,3 +151,111 @@ let f_IWSAM_flex_StaticProperty(x: #IStaticProperty<'T>) = let expected = Some(3, 3, 20, 34) GoToDefinitionTest(fileContents, caretMarker, expected) + + let private symbolUseAt (document: Document) (sourceText: SourceText) position = + maybe { + let textLine = sourceText.Lines.GetLineFromPosition position + let fcsTextLineNumber = Line.fromZ (sourceText.Lines.GetLinePosition position).Line + + let! lexerSymbol = + Tokenizer.getSymbolAtPosition ( + document.Id, + sourceText, + position, + document.FilePath, + [], + SymbolLookupKind.Greedy, + false, + false, + None, + CancellationToken.None + ) + + let _, checkFileResults = + document.GetFSharpParseAndCheckResultsAsync userOpName + |> CancellableTask.runSynchronouslyWithoutCancellation + + return! + checkFileResults.GetSymbolUseAtLocation( + fcsTextLineNumber, + lexerSymbol.Ident.idRange.EndColumn, + textLine.ToString(), + lexerSymbol.FullIsland + ) + } + + /// An app project referencing a library project. The app document comes from a snapshot that + /// predates the library's document, the way Roslyn hands out documents while a solution is + /// still loading, while the workspace's current solution already has it. + module internal StaleSnapshot = + + let library = SyntheticProject.Create("Library", sourceFile "Library" []) + + let app = + { SyntheticProject.Create( + "App", + { sourceFile "App" [ "Library" ] with + ExtraSource = "let mapped = List.map id [ 1 ]" + } + ) with + DependsOn = [ library ] + } + + let solution, _ = RoslynTestHelpers.CreateMultiProjectSolution app + let appPath = app.GetFilePath "App" + let libraryPath = library.GetFilePath "Library" + + let private documentId path = + solution.GetDocumentIdsWithFilePath path |> Seq.exactlyOne + + let appDocument = + solution.RemoveDocument(documentId libraryPath).GetDocument(documentId appPath) + + let appSourceText = appDocument.GetTextAsync(CancellationToken.None).Result + + /// The position of the last character of the text, inside the identifier it ends with. + let positionOf (text: string) = + appSourceText.ToString().IndexOf(text, StringComparison.Ordinal) + text.Length + - 1 + + let findDefinitionAt position = + GoToDefinition(FSharpMetadataAsSourceService()).FindDefinitionAtPosition(appDocument, position) + |> CancellableTask.runSynchronouslyWithoutCancellation + + [] + let ``goto definition finds the target document through the workspace when the origin snapshot predates it`` () = + let position = StaleSnapshot.positionOf "ModuleLibrary.f" + let document = StaleSnapshot.appDocument + + let range = + findDefinition (document, StaleSnapshot.appSourceText, position, [], None) + |> Option.defaultWith (fun () -> failwith "declaration not found") + + Assert.Equal(StaleSnapshot.libraryPath, range.FileName) + Assert.True(Option.isNone (document.Project.Solution.TryGetDocumentFromFSharpRange(range, document.Project.Id))) + + match document.TryGetSolutionDocumentFromFSharpRange range with + | ValueSome target -> Assert.Equal(StaleSnapshot.libraryPath, target.FilePath) + | ValueNone -> failwith "the workspace's current solution has the library document" + + match StaleSnapshot.findDefinitionAt position with + | ValueSome(FSharpGoToDefinitionResult.NavigableItem item, _) -> Assert.Equal(StaleSnapshot.libraryPath, item.Document.FilePath) + | result -> failwith $"expected a navigable item, got %A{result}" + + [] + let ``goto definition treats a symbol whose file is in no solution as external`` () = + match StaleSnapshot.findDefinitionAt (StaleSnapshot.positionOf "List.map") with + | ValueSome(FSharpGoToDefinitionResult.ExternalAssembly _, _) -> () + | result -> failwith $"expected an external assembly, got %A{result}" + + [] + let ``find references scope includes the declaring project the origin snapshot does not know`` () = + let position = StaleSnapshot.positionOf "ModuleLibrary.f" + + let symbolUse = + symbolUseAt StaleSnapshot.appDocument StaleSnapshot.appSourceText position + |> Option.defaultWith (fun () -> failwith "symbol not found") + + match symbolUse.GetSymbolScope StaleSnapshot.appDocument with + | Some(SymbolScope.Projects(projects, _)) -> Assert.Contains(StaleSnapshot.library.Name, projects |> List.map _.Name) + | scope -> failwith $"expected a project scope, got %A{scope}" diff --git a/vsintegration/tests/FSharp.Editor.Tests/Helpers/RoslynHelpers.fs b/vsintegration/tests/FSharp.Editor.Tests/Helpers/RoslynHelpers.fs index 25509f14ace..38a69096929 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/Helpers/RoslynHelpers.fs +++ b/vsintegration/tests/FSharp.Editor.Tests/Helpers/RoslynHelpers.fs @@ -6,15 +6,22 @@ open System open System.IO open System.Reflection open System.Linq +open System.Collections.Concurrent open System.Collections.Generic open System.Collections.Immutable +open System.Threading +open System.Threading.Tasks open Microsoft.CodeAnalysis +open Microsoft.CodeAnalysis.CSharp +open Microsoft.CodeAnalysis.ExternalAccess.FSharp.Editor.FindUsages +open Microsoft.CodeAnalysis.ExternalAccess.FSharp.FindUsages open Microsoft.VisualStudio.Composition open Microsoft.CodeAnalysis.Host open Microsoft.CodeAnalysis.Text open Microsoft.VisualStudio.FSharp.Editor open Microsoft.CodeAnalysis.Host.Mef open FSharp.Compiler.CodeAnalysis +open FSharp.Compiler.Diagnostics open FSharp.Test.ProjectGeneration [] @@ -27,6 +34,7 @@ module MefHelpers = let imports = [| "Microsoft.CodeAnalysis.Workspaces.dll" + "Microsoft.CodeAnalysis.CSharp.Workspaces.dll" "Microsoft.VisualStudio.Shell.15.0.dll" "Microsoft.VisualStudio.Platform.VSEditor.dll" "FSharp.Editor.dll" @@ -170,8 +178,7 @@ type TestHostWorkspaceServices(hostServices: HostServices, workspace: Workspace) |> Seq.distinctBy (fun x -> x.Key) |> System.Collections.Concurrent.ConcurrentDictionary - let langServices = - new TestHostLanguageServices(this, LanguageNames.FSharp, exportProvider) + let languageServices = ConcurrentDictionary() override _.Workspace = workspace @@ -189,9 +196,10 @@ type TestHostWorkspaceServices(hostServices: HostServices, workspace: Workspace) override _.FindLanguageServices(_filter) = Seq.empty override _.GetLanguageServices(languageName) = - match languageName with - | LanguageNames.FSharp -> langServices :> HostLanguageServices - | _ -> raise (NotSupportedException(sprintf "Language '%s' not supported in FSharp VS tests." languageName)) + languageServices.GetOrAdd( + languageName, + (fun language -> new TestHostLanguageServices(this, language, exportProvider) :> HostLanguageServices) + ) override _.HostServices = hostServices @@ -201,6 +209,14 @@ type TestHostServices() = override this.CreateWorkspaceServices(workspace) = new TestHostWorkspaceServices(this, workspace) +/// One Roslyn project instance of a multi-targeted F# project: its extra defines and the +/// synthetic files left out of it, as VS does per target framework. +type TargetInstance = + { + Defines: string list + ExcludedFileIds: string list + } + [] type RoslynTestHelpers private () = @@ -224,7 +240,8 @@ type RoslynTestHelpers private () = match extension with | ".fsx" -> SourceCodeKind.Script - | ".fsi" -> SourceCodeKind.Regular + | ".fsi" + | ".cs" -> SourceCodeKind.Regular | ".fs" -> SourceCodeKind.Regular | _ -> failwith "not supported" @@ -258,6 +275,33 @@ type RoslynTestHelpers private () = filePath = filePath ) + static member private ProjectInfoFor + (id, name, filePath, outputFilePath, documents, projectReferences: ProjectReference list, metadataReferences: MetadataReference seq) + = + ProjectInfo.Create( + id, + VersionStamp.Create(DateTime.UtcNow), + name, + name, + LanguageNames.FSharp, + filePath = filePath, + outputFilePath = outputFilePath, + documents = documents, + projectReferences = projectReferences, + metadataReferences = metadataReferences + ) + + static member private MetadataReferencesOf(options: FSharpProjectOptions, excludedPaths: string seq) = + let excluded = HashSet(excludedPaths, StringComparer.OrdinalIgnoreCase) + + options.OtherOptions + |> Seq.filter (fun x -> x.StartsWith("-r:", StringComparison.Ordinal)) + |> Seq.map _.Substring(3) + |> Seq.filter (excluded.Contains >> not) + |> Seq.map MetadataReference.CreateFromFile + |> Seq.cast + |> Seq.toList + static member SetProjectOptions projId (solution: Solution) (options: FSharpProjectOptions) = solution.Workspace.Services .GetService() @@ -270,6 +314,92 @@ type RoslynTestHelpers private () = static member SetEditorOptions (solution: Solution) options = solution.Workspace.Services.GetService().With(options) + static member CreateFindUsagesContext() = + let foundDefinitions = ConcurrentBag() + let foundReferences = ConcurrentBag() + + let context = + { new IFSharpFindUsagesContext with + member _.OnDefinitionFoundAsync definition = + foundDefinitions.Add definition + Task.CompletedTask + + member _.OnReferenceFoundAsync reference = + foundReferences.Add reference + Task.CompletedTask + + member _.ReportMessageAsync _ = Task.CompletedTask + member _.ReportProgressAsync(_, _) = Task.CompletedTask + member _.SetSearchTitleAsync _ = Task.CompletedTask + member _.CancellationToken = CancellationToken.None + } + + context, foundDefinitions, foundReferences + + /// Compiles the synthetic project to its OutputFilename with the options the checker sees. + static member CompileToAssembly(syntheticProject: SyntheticProject, checker: FSharpChecker) = + let options = syntheticProject.GetProjectOptions checker + + let diagnostics, exn = + checker.Compile + [| + "fsc.exe" + "--target:library" + $"-o:{syntheticProject.OutputFilename}" + yield! options.OtherOptions + yield! options.SourceFiles + |] + |> Async.RunSynchronously + + exn |> Option.iter raise + + match + diagnostics + |> Array.filter (fun d -> d.Severity = FSharpDiagnosticSeverity.Error) + with + | [||] -> syntheticProject.OutputFilename + | errors -> failwith $"Compilation of {syntheticProject.Name} failed: %A{errors}" + + /// Adds a C# library that references the framework of `options` and the given assemblies, the way + /// VS references an F# project from C# through its built assembly. + static member AddCSharpProject + (solution: Solution, name: string, source: string, options: FSharpProjectOptions, referencedAssemblies: string list) + = + let projectId = ProjectId.CreateNewId() + let projectDir = $"C:\\{name}" + + let projectInfo = + ProjectInfo.Create( + projectId, + VersionStamp.Create(DateTime.UtcNow), + name, + name, + LanguageNames.CSharp, + filePath = Path.Combine(projectDir, $"{name}.csproj"), + compilationOptions = CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary), + documents = + [ + RoslynTestHelpers.CreateDocumentInfo projectId (Path.Combine(projectDir, "Program.cs")) source + ], + metadataReferences = RoslynTestHelpers.MetadataReferencesOf(options, []) + ) + + let workspace = solution.Workspace :?> AdhocWorkspace + let project = workspace.AddProject projectInfo + + // AdhocWorkspace.AddProject turns a reference to another project's output into a project reference, + // whereas VS keeps a C# → F# reference as metadata; the assemblies are added afterwards. + let withAssemblies = + referencedAssemblies + |> List.fold + (fun (project: Project) assembly -> project.AddMetadataReference(MetadataReference.CreateFromFile assembly)) + project + + if not (workspace.TryApplyChanges withAssemblies.Solution) then + failwith $"Could not add the references of {name}" + + workspace.CurrentSolution + static member CreateSolution(source, ?options: FSharpProjectOptions, ?extraFSharpProjectOtherOptions: string array, ?editorOptions) = let projId = ProjectId.CreateNewId() @@ -331,12 +461,8 @@ type RoslynTestHelpers private () = let options = syntheticProject.GetProjectOptions checker - let metadataReferences = - options.OtherOptions - |> Seq.filter (fun x -> x.StartsWith("-r:")) - |> Seq.map (fun x -> x.Substring(3) |> MetadataReference.CreateFromFile :> MetadataReference) - - let projInfo = projInfo.WithMetadataReferences metadataReferences + let projInfo = + projInfo.WithMetadataReferences(RoslynTestHelpers.MetadataReferencesOf(options, [])) let solution = RoslynTestHelpers.CreateSolution [ projInfo ] @@ -344,6 +470,110 @@ type RoslynTestHelpers private () = solution, checker + /// One Roslyn project per synthetic project, wired with project references the way VS wires + /// project-to-project references, so the options manager builds in-memory F# references. + static member CreateMultiProjectSolution(syntheticProject: SyntheticProject) = + let checker = syntheticProject.SaveAndCheck() + + let projects = + syntheticProject.GetAllProjects() + |> Seq.distinctBy _.Name + |> Seq.map (fun project -> project, ProjectId.CreateNewId()) + |> Seq.toList + + let projectIds = dict [ for project, id in projects -> project.Name, id ] + + let projectInfos = + [ + for project, id in projects do + let options = project.GetProjectOptions checker + + RoslynTestHelpers.ProjectInfoFor( + id, + project.Name, + project.ProjectFileName, + project.OutputFilename, + [ + for path in project.SourceFilePaths -> RoslynTestHelpers.CreateDocumentInfo id path (File.ReadAllText path) + ], + [ + for dependency in project.DependsOn -> ProjectReference projectIds[dependency.Name] + ], + RoslynTestHelpers.MetadataReferencesOf(options, project.DependsOn |> List.map _.OutputFilename) + ) + ] + + let solution = RoslynTestHelpers.CreateSolution projectInfos + + for project, id in projects do + project.GetProjectOptions checker + |> RoslynTestHelpers.SetProjectOptions id solution + + solution, checker + + /// One Roslyn project per target instance, all sharing the .fsproj path and the document file + /// paths, like the per-target-framework projects VS creates for a multi-targeted project. + static member CreateMultiTargetSolution(syntheticProject: SyntheticProject, instances: TargetInstance list) = + assert (syntheticProject.DependsOn = []) + + let checker = syntheticProject.SaveAndCheck() + let options = syntheticProject.GetProjectOptions checker + let metadataReferences = RoslynTestHelpers.MetadataReferencesOf(options, []) + + let instances = + [ + for instance in instances -> + let excludedPaths = + HashSet( + [ + for fileId in instance.ExcludedFileIds do + syntheticProject.GetFilePath fileId + + if (syntheticProject.Find fileId).HasSignatureFile then + syntheticProject.GetSignatureFilePath fileId + ], + StringComparer.OrdinalIgnoreCase + ) + + let sourceFiles = + syntheticProject.SourceFilePaths |> List.filter (excludedPaths.Contains >> not) + + let id = ProjectId.CreateNewId() + + let projectInfo = + RoslynTestHelpers.ProjectInfoFor( + id, + syntheticProject.Name, + syntheticProject.ProjectFileName, + syntheticProject.OutputFilename, + [ + for path in sourceFiles -> RoslynTestHelpers.CreateDocumentInfo id path (File.ReadAllText path) + ], + [], + metadataReferences + ) + + let instanceOptions = + { options with + SourceFiles = List.toArray sourceFiles + OtherOptions = + [| + yield! options.OtherOptions + for define in instance.Defines -> $"--define:{define}" + |] + } + + id, projectInfo, instanceOptions + ] + + let solution = + RoslynTestHelpers.CreateSolution [ for _, projectInfo, _ in instances -> projectInfo ] + + for id, _, instanceOptions in instances do + RoslynTestHelpers.SetProjectOptions id solution instanceOptions + + solution, [ for id, _, _ in instances -> id ] + static member GetFsDocument(code, ?customProjectOption: string, ?customEditorOptions) = let customProjectOptions = customProjectOption diff --git a/vsintegration/tests/FSharp.Editor.Tests/MultiTargetFindReferencesTests.fs b/vsintegration/tests/FSharp.Editor.Tests/MultiTargetFindReferencesTests.fs new file mode 100644 index 00000000000..96fe6843f38 --- /dev/null +++ b/vsintegration/tests/FSharp.Editor.Tests/MultiTargetFindReferencesTests.fs @@ -0,0 +1,144 @@ +// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information. + +/// One project loaded as two target-framework instances: `plain` compiles without the fourth file +/// and without FOO, `foo` compiles everything with FOO defined. Both define COMMON. +module FSharp.Editor.Tests.MultiTargetFindReferencesTests + +open System +open System.IO +open System.Threading +open Xunit +open Microsoft.CodeAnalysis +open Microsoft.CodeAnalysis.ExternalAccess.FSharp.Editor.FindUsages +open Microsoft.VisualStudio.FSharp.Editor +open Microsoft.VisualStudio.FSharp.Editor.CancellableTasks +open FSharp.Compiler.Text +open FSharp.Editor.Tests.Helpers +open FSharp.Test.ProjectGeneration + +let private project = + SyntheticProject.Create( + { sourceFile "First" [] with + SignatureFile = AutoGenerated + ExtraSource = "let sharedFunc funcParam = funcParam * 2\n" + }, + { sourceFile "Second" [ "First" ] with + ExtraSource = "let plainUse x = ModuleFirst.sharedFunc x" + }, + { sourceFile "Third" [ "First" ] with + ExtraSource = "#if FOO\nlet fooUse x = ModuleFirst.sharedFunc x\n#endif\nlet useBesideFoo x = ModuleFirst.sharedFunc x" + }, + { sourceFile "Fourth" [ "First" ] with + ExtraSource = "let fooOnlyFileUse x = ModuleFirst.sharedFunc x" + }, + { sourceFile "Fifth" [ "First" ] with + ExtraSource = "#if COMMON\nlet commonUse x = ModuleFirst.sharedFunc x\n#endif" + } + ) + +let private solution, plainId, fooId = + let solution, instances = + RoslynTestHelpers.CreateMultiTargetSolution( + project, + [ + { + Defines = [ "COMMON" ] + ExcludedFileIds = [ "Fourth" ] + } + { + Defines = [ "COMMON"; "FOO" ] + ExcludedFileIds = [] + } + ] + ) + + match instances with + | [ plainId; fooId ] -> solution, plainId, fooId + | _ -> failwith "two instances expected" + +let private firstPath = project.GetFilePath "First" + +let private declarationPosition = + (File.ReadAllText firstPath).IndexOf("sharedFunc", StringComparison.Ordinal) + +let private instanceOf index = if index = 0 then plainId else fooId + +let private documentIn (projectId: ProjectId) path = + solution.GetDocumentIdsWithFilePath path + |> Seq.find (fun id -> id.ProjectId = projectId) + |> solution.GetDocument + +[] +[] +[] +let ``every file is searched once, files under conditional compilation and instance-only files included`` (instance: int) = + let context, foundDefinitions, foundReferences = + RoslynTestHelpers.CreateFindUsagesContext() + + (FSharpFindUsagesService() :> IFSharpFindUsagesService) + .FindReferencesAsync(documentIn (instanceOf instance) firstPath, declarationPosition, context) + .Wait() + + Assert.Equal(1, foundDefinitions.Count) + // The signature, Second, both uses in Third, Fourth compiled only into foo, and Fifth under the + // define both instances share: one hit each, not one per instance. + Assert.Equal(6, foundReferences.Count) + +/// What Rename works from: the symbol's uses grouped by Roslyn document. +let private usesByDocument (document: Document) = + let sourceText = document.GetTextAsync(CancellationToken.None).Result + let textLine = sourceText.Lines.GetLineFromPosition declarationPosition + + let fcsLine = Line.fromZ (sourceText.Lines.GetLinePosition declarationPosition).Line + + let lexerSymbol = + Tokenizer.getSymbolAtPosition ( + document.Id, + sourceText, + declarationPosition, + document.FilePath, + [], + SymbolLookupKind.Greedy, + false, + false, + None, + CancellationToken.None + ) + |> Option.defaultWith (fun () -> failwith "symbol not found") + + let _, checkFileResults = + document.GetFSharpParseAndCheckResultsAsync "MultiTargetFindReferences" + |> CancellableTask.runSynchronouslyWithoutCancellation + + let symbolUse = + checkFileResults.GetSymbolUseAtLocation(fcsLine, lexerSymbol.Ident.idRange.EndColumn, textLine.ToString(), lexerSymbol.FullIsland) + |> Option.defaultWith (fun () -> failwith "symbol use not found") + + SymbolHelpers.getSymbolUsesInSolution (symbolUse, checkFileResults, document) + |> CancellableTask.runSynchronouslyWithoutCancellation + +[] +[] +[] +let ``rename gets every use once, from an instance that compiles its file`` (instance: int) = + let uses = usesByDocument (documentIn (instanceOf instance) firstPath) + + let located = + [ + for KeyValue(documentId, ranges) in uses do + for range in ranges -> solution.GetDocument(documentId).FilePath, range + ] + + // A file several instances compile is searched in each of them; the same use must reach Rename once. + Assert.Equal<(string * range) list>(List.distinct located, located) + + let files = located |> List.map fst |> List.distinct + + for fileId in [ "Second"; "Third"; "Fourth"; "Fifth" ] do + Assert.Contains(project.GetFilePath fileId, files) + + for documentId in uses.Keys do + let compiledHere = + solution.GetProject(documentId.ProjectId).Documents |> Seq.map _.FilePath + + Assert.Contains(solution.GetDocument(documentId).FilePath, compiledHere) diff --git a/vsintegration/tests/FSharp.Editor.Tests/MultiTargetNavigateToSearchTests.fs b/vsintegration/tests/FSharp.Editor.Tests/MultiTargetNavigateToSearchTests.fs new file mode 100644 index 00000000000..c1febedfd5e --- /dev/null +++ b/vsintegration/tests/FSharp.Editor.Tests/MultiTargetNavigateToSearchTests.fs @@ -0,0 +1,70 @@ +// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information. + +/// One project loaded as two target-framework instances: `plain` compiles without the fourth file +/// and without FOO, `foo` compiles everything with FOO defined. +module FSharp.Editor.Tests.MultiTargetNavigateToSearchTests + +open System.Collections.Immutable +open System.Threading +open Xunit +open Microsoft.CodeAnalysis.ExternalAccess.FSharp.NavigateTo +open FSharp.Editor.Tests.Helpers +open FSharp.Test.ProjectGeneration + +let private project = + SyntheticProject.Create( + { sourceFile "First" [] with + ExtraSource = "let sharedFunc funcParam = funcParam * 2\n" + }, + { sourceFile "Second" [ "First" ] with + ExtraSource = "let plainUse x = ModuleFirst.sharedFunc x" + }, + { sourceFile "Third" [ "First" ] with + ExtraSource = "#if FOO\nlet fooUse x = ModuleFirst.sharedFunc x\n#endif" + }, + { sourceFile "Fourth" [ "First" ] with + ExtraSource = "let fooOnlyFileUse x = ModuleFirst.sharedFunc x" + } + ) + +let private solution, instances = + RoslynTestHelpers.CreateMultiTargetSolution( + project, + [ + { + Defines = [] + ExcludedFileIds = [ "Fourth" ] + } + { + Defines = [ "FOO" ] + ExcludedFileIds = [] + } + ] + ) + +let private service: IFSharpNavigateToSearchService = + MefHelpers.createExportProvider().GetExportedValue() + +/// Every instance searched in solution order, as the NavigateTo searcher does. +let private search pattern = + [ + for id in instances do + yield! + service + .SearchProjectAsync( + solution.GetProject id, + ImmutableArray.Empty, + pattern, + service.KindsProvided, + CancellationToken.None + ) + .Result + ] + +[] +[] +[] +[] +let ``a declaration is reported once across the instances of a project`` (name: string) = + let names = search name |> List.map _.Name |> List.filter ((=) name) + Assert.Equal([ name ], names) diff --git a/vsintegration/tests/FSharp.Editor.Tests/NavigateToSearchWhileLoadingTests.fs b/vsintegration/tests/FSharp.Editor.Tests/NavigateToSearchWhileLoadingTests.fs new file mode 100644 index 00000000000..0f171ded8ca --- /dev/null +++ b/vsintegration/tests/FSharp.Editor.Tests/NavigateToSearchWhileLoadingTests.fs @@ -0,0 +1,132 @@ +// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information. + +/// Navigate To runs a search of its own while the solution is still loading, and nothing it searches +/// then has its compilation options yet. +module FSharp.Editor.Tests.NavigateToSearchWhileLoadingTests + +open System +open System.Collections.Immutable +open System.Threading +open System.Threading.Tasks + +open Xunit + +open Microsoft.CodeAnalysis +open Microsoft.CodeAnalysis.ExternalAccess.FSharp.NavigateTo + +open FSharp.Editor.Tests.Helpers + +/// A project the project system has not yet handed its command line options: the state every project +/// of the solution is in until it has loaded. +let private loadingProject name source = + let projectId = ProjectId.CreateNewId() + + [ RoslynTestHelpers.CreateDocumentInfo projectId $"C:\\{name}.fs" source ] + |> RoslynTestHelpers.CreateProjectInfo projectId $"C:\\{name}.fsproj" + +let private loadingSolution source = + let project = loadingProject "test" source + let solution = RoslynTestHelpers.CreateSolution [ project ] + project.Id, solution, solution.Projects |> Seq.exactlyOne + +/// One export provider per test: the navigable items are cached in a shared one. +let private searchServices () = + let service: IFSharpNavigateToSearchService = + MefHelpers.createExportProvider().GetExportedValue() + + service, service :?> IFSharpAdvancedNavigateToSearchService + +let private namesFound (results: ImmutableArray) = results |> Seq.map _.Name |> Seq.toList + +/// The loading search as the searcher drives it: results and project completions arrive through callbacks. +/// Returns the names found and how many times a project was reported complete. +let private searchWhileLoading + (service: IFSharpNavigateToSearchService, advanced: IFSharpAdvancedNavigateToSearchService) + (projects: Project list) + pattern + = + task { + let found = ResizeArray() + let completed = ref 0 + + do! + advanced.SearchCachedDocumentsAsync( + (List.head projects).Solution, + ImmutableArray.CreateRange projects, + ImmutableArray.Empty, + pattern, + service.KindsProvided, + null, + (fun results -> + lock found (fun () -> found.AddRange results) + Task.CompletedTask), + (fun () -> + Interlocked.Increment &completed.contents |> ignore + Task.CompletedTask), + CancellationToken.None + ) + + return found |> Seq.map _.Name |> Seq.toList, completed.Value + } + +[] +let ``the loading search finds what the search that waits for the options cannot`` () : Task = + task { + let _, _, project = + loadingSolution "module Sample =\n let declaredWhileLoading = 1\n" + + let (service, _) as services = searchServices () + + let searchAccurately () = + service.SearchProjectAsync(project, ImmutableArray.Empty, "declaredWhileLoading", service.KindsProvided, CancellationToken.None) + :> Task + + let! _ = Assert.ThrowsAnyAsync(fun () -> searchAccurately ()) + + let! names, completed = searchWhileLoading services [ project ] "declaredWhileLoading" + + Assert.Equal([ "declaredWhileLoading" ], names) + Assert.Equal(1, completed) + + // What the loading search left in the cache must not be handed to the accurate search: it was + // read without the project's defines. + let! _ = Assert.ThrowsAnyAsync(fun () -> searchAccurately ()) + () + } + +[] +let ``the loading search reads a file under the wrong defines and does not keep the answer`` () : Task = + task { + let projectId, solution, project = + loadingSolution "#if FOO\nlet fooOnly = 1\n#endif\n" + + let (service, _) as services = searchServices () + + let! namesWhileLoading, _ = searchWhileLoading services [ project ] "fooOnly" + Assert.Equal([], namesWhileLoading) + + { RoslynTestHelpers.DefaultProjectOptions with + OtherOptions = [| "--define:FOO" |] + } + |> RoslynTestHelpers.SetProjectOptions projectId solution + + let! found = service.SearchProjectAsync(project, ImmutableArray.Empty, "fooOnly", service.KindsProvided, CancellationToken.None) + + Assert.Equal([ "fooOnly" ], namesFound found) + } + +[] +let ``every project is reported complete once, whether or not anything is found in it`` () : Task = + task { + let solution = + RoslynTestHelpers.CreateSolution + [ + loadingProject "first" "let found = 1\n" + loadingProject "second" "let other = 2\n" + ] + + let! names, completed = searchWhileLoading (searchServices ()) (solution.Projects |> Seq.toList) "found" + + Assert.Equal([ "found" ], names) + Assert.Equal(2, completed) + } diff --git a/vsintegration/tests/FSharp.Editor.Tests/PathMapNavigationTests.fs b/vsintegration/tests/FSharp.Editor.Tests/PathMapNavigationTests.fs new file mode 100644 index 00000000000..0fc8c7ffe77 --- /dev/null +++ b/vsintegration/tests/FSharp.Editor.Tests/PathMapNavigationTests.fs @@ -0,0 +1,66 @@ +// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information. + +/// A library whose build maps its source paths, as DeterministicSourcePaths does: the symbols another +/// project imports from it must still name the files of the workspace. +module FSharp.Editor.Tests.PathMapNavigationTests + +open System +open System.IO +open System.Threading +open Xunit +open Microsoft.VisualStudio.FSharp.Editor +open Microsoft.VisualStudio.FSharp.Editor.CancellableTasks +open FSharp.Editor.Tests.Helpers +open FSharp.Test.ProjectGeneration + +/// As Directory.Build.props would set it: the same map on every project of the solution. +let private pathMap (project: SyntheticProject) = + [ $"--pathmap:{Path.GetDirectoryName project.ProjectDir}=.\\" ] + +let private library = + let library = SyntheticProject.Create("Library", sourceFile "Library" []) + + { library with + OtherOptions = pathMap library + } + +let private app = + let app = SyntheticProject.Create("App", sourceFile "App" [ "Library" ]) + + { app with + DependsOn = [ library ] + OtherOptions = pathMap app + } + +let private solution, _ = RoslynTestHelpers.CreateMultiProjectSolution app + +let private documentOf (project: SyntheticProject) fileId = + solution.GetDocumentIdsWithFilePath(project.GetFilePath fileId) + |> Seq.exactlyOne + |> solution.GetDocument + +[] +let ``the path map of a project is not applied in the IDE`` () = + let _, _, _, options = + (documentOf library "Library").GetFSharpCompilationOptionsAsync "test" + |> CancellableTask.runSynchronouslyWithoutCancellation + + Assert.DoesNotContain(options.OtherOptions, fun option -> option.StartsWith("--pathmap:", StringComparison.Ordinal)) + +[] +let ``goto definition into a project built with a path map reaches its source`` () = + let appDocument = documentOf app "App" + let text = appDocument.GetTextAsync(CancellationToken.None).Result.ToString() + + let position = + text.IndexOf("ModuleLibrary.f", StringComparison.Ordinal) + + "ModuleLibrary.f".Length + - 1 + + let result = + GoToDefinition(FSharpMetadataAsSourceService()).FindDefinitionAtPosition(appDocument, position) + |> CancellableTask.runSynchronouslyWithoutCancellation + + match result with + | ValueSome(FSharpGoToDefinitionResult.NavigableItem item, _) -> Assert.Equal(library.GetFilePath "Library", item.Document.FilePath) + | result -> failwith $"expected a navigable item, got %A{result}" diff --git a/vsintegration/tests/FSharp.Editor.Tests/SemanticClassificationServiceTests.fs b/vsintegration/tests/FSharp.Editor.Tests/SemanticClassificationServiceTests.fs index 6c58f658ee9..183ad76d1be 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/SemanticClassificationServiceTests.fs +++ b/vsintegration/tests/FSharp.Editor.Tests/SemanticClassificationServiceTests.fs @@ -2,12 +2,18 @@ namespace FSharp.Editor.Tests +open System +open System.Collections.Generic +open System.Threading +open System.Threading.Tasks open Xunit +open Microsoft.CodeAnalysis open Microsoft.VisualStudio.FSharp.Editor open FSharp.Compiler.EditorServices open FSharp.Compiler.Text open Microsoft.CodeAnalysis.Text open Microsoft.CodeAnalysis.Classification +open Microsoft.CodeAnalysis.ExternalAccess.FSharp.Classification open FSharp.Compiler.CodeAnalysis open FSharp.Editor.Tests.Helpers open FSharp.Test @@ -31,6 +37,61 @@ type SemanticClassificationServiceTests() = |> Option.toList |> List.collect Array.toList + let openDocument (source: string) = + let solution = RoslynTestHelpers.CreateSolution source + let workspace = solution.Workspace + let documentId = (RoslynTestHelpers.GetSingleDocument solution).Id + workspace.OpenDocument documentId + let document = workspace.CurrentSolution.GetDocument documentId + Assert.True(workspace.IsDocumentOpen documentId, "The document under test has to be open.") + document + + let sourceTextOf (document: Document) = + document.GetTextAsync(CancellationToken.None).GetAwaiter().GetResult() + + let classifyWith (ct: CancellationToken) (document: Document) (span: TextSpan) = + let result = ResizeArray() + + (FSharpClassificationService() :> IFSharpClassificationService) + .AddSemanticClassificationsAsync(document, span, result, ct) + .GetAwaiter() + .GetResult() + + List.ofSeq result + + let classify document span = + classifyWith CancellationToken.None document span + + let isCached (cache: DocumentCache) (document: Document) = + (cache.TryGetValueAsync document CancellationToken.None).GetAwaiter().GetResult().IsSome + + let versionOf (document: Document) = + document.GetTextVersionAsync(CancellationToken.None).GetAwaiter().GetResult() + + let isRemembered (document: Document) = + match FSharpClassificationService.OpenDocumentClassifications.TryGetValue document.Id with + | true, classification -> classification.Version = versionOf document + | _ -> false + + let lineSpan (text: SourceText) firstLine lastLine = + TextSpan.FromBounds(text.Lines[firstLine].Start, text.Lines[lastLine].End) + + let clearProjectOptions (document: Document) = + document.Project.Solution.Workspace.Services.GetService().FSharpProjectOptionsManager.ClearAllCaches() + + // A project whose options were never supplied, i.e. one Visual Studio is still loading. + let openDocumentWithoutProjectOptions (source: string) = + let projectId = ProjectId.CreateNewId() + let documentInfo = RoslynTestHelpers.CreateDocumentInfo projectId "test.fs" source + + let projectInfo = + RoslynTestHelpers.CreateProjectInfo projectId "test.fsproj" [ documentInfo ] + + let solution = RoslynTestHelpers.CreateSolution [ projectInfo ] + let documentId = (RoslynTestHelpers.GetSingleDocument solution).Id + solution.Workspace.OpenDocument documentId + solution.Workspace.CurrentSolution.GetDocument documentId + let verifyClassificationAtEndOfMarker (fileContents: string, marker: string, classificationType: string) = let text = SourceText.From(fileContents) let ranges = getRanges fileContents @@ -416,3 +477,174 @@ let result2 = s.(*2*)IsHyperbolicCaseWithLongName && Range.rangeContainsPos item.Range longCasePos) Assert.True(longCaseUnionItems.Length > 0, "Expected a UnionCase classification covering 'IsHyperbolicCaseWithLongName'") + + // Which cache a document lands in is invisible in its classifications - a miss only costs a + // recheck - so these reach the caches directly. Splitting one cache in two (#15954) left the + // open-document branch reading the opened cache and writing the unopened one, so the opened + // cache was never populated and every request for an open file re-ran the checker. + [] + member _.``Semantic classification of an open document is cached for opened documents``() = + let document = openDocument "let x = 1" + let text = sourceTextOf document + + Assert.NotEmpty(classify document (TextSpan(0, text.Length))) + + Assert.True(isRemembered document, "Classifying an open document must remember its classification.") + + Assert.False( + isCached FSharpClassificationService.UnopenedDocumentsSemanticClassificationCache document, + "An open document must not be cached as an unopened one." + ) + + // The cache is keyed by text version only, so what it holds has to cover the whole file: + // Roslyn asks for the visible span, then for other spans of the same version as the user scrolls. + [] + member _.``Semantic classification computed for one span of an open document serves another span at the same version``() = + let source = + [ + "type R = { Doop: int }" + "let r = { Doop = 12 }" + "" + "let mutable first = 12" + "let g () = first" + ] + |> String.concat "\n" + + let document = openDocument source + let text = sourceTextOf document + let spanA = lineSpan text 0 1 + let spanB = lineSpan text 3 4 + Assert.False(spanA.IntersectsWith spanB) + + let first = classify document spanA + Assert.NotEmpty first + + let second = classify document spanB + Assert.NotEmpty second + Assert.All(second, fun span -> Assert.True(spanB.Contains span.TextSpan)) + + // A freshly opened copy has a new DocumentId and therefore cold caches: a direct computation for B. + Assert.Equal(classify (openDocument source) spanB, second) + Assert.Equal(first, classify document spanA) + + // Roslyn replaces a span's tags with whatever comes back, so "no result" must re-emit the last + // good one rather than strip the colours the user already sees. + [] + member _.``Semantic classification serves the last good lookup when project options become unavailable``() = + let source = "let x = 1\nlet y = 2" + let document = openDocument source + let text = sourceTextOf document + Assert.NotEmpty(classify document (TextSpan(0, text.Length))) + + clearProjectOptions document + // A new text version misses the versioned cache; the text itself is unchanged, so the last + // good lookup still describes it. + let reopened = document.WithText(SourceText.From source) + let reopenedText = sourceTextOf reopened + + Assert.NotEmpty(classify reopened (TextSpan(0, reopenedText.Length))) + + Assert.False(isRemembered reopened, "A miss must not be remembered as a result.") + + // The lookup names positions in the text it was computed from, so against edited text it would + // colour the wrong characters. + [] + member _.``Semantic classification does not serve the last good lookup for edited text``() = + let document = openDocument "let x = 1\nlet y = 2" + let text = sourceTextOf document + Assert.NotEmpty(classify document (TextSpan(0, text.Length))) + + clearProjectOptions document + let edited = document.WithText(SourceText.From "// a comment\nlet x = 1\nlet y = 2") + let editedText = sourceTextOf edited + + Assert.Empty(classify edited (TextSpan(0, editedText.Length))) + + [] + member _.``Semantic classification without project options and without an earlier result returns nothing and caches nothing``() = + let document = openDocumentWithoutProjectOptions "let x = 1" + let text = sourceTextOf document + + Assert.Empty(classify document (TextSpan(0, text.Length))) + Assert.False(isRemembered document) + Assert.False(isCached FSharpClassificationService.UnopenedDocumentsSemanticClassificationCache document) + + // Roslyn keeps the previous tags only for a cancellation carrying its own token; nothing may catch it. + [] + member _.``Semantic classification propagates cancellation as a canceled task``() = + let document = openDocument "let x = 1" + let text = sourceTextOf document + + let task = + (FSharpClassificationService() :> IFSharpClassificationService) + .AddSemanticClassificationsAsync(document, TextSpan(0, text.Length), ResizeArray(), CancellationToken(true)) + + Assert.ThrowsAny(fun () -> task.GetAwaiter().GetResult()) + |> ignore + + Assert.True task.IsCanceled + + // Roslyn's viewport taggers ask for the same version at once; the file must be walked once for all of them. + [] + member _.``Overlapping requests for one version share a single classification``() = + let gate = TaskCompletionSource() + let computed = ref 0 + + let inFlight = + InFlightClassification( + VersionStamp.Create(), + fun _ -> + Interlocked.Increment computed |> ignore + gate.Task + ) + + let first = inFlight.Join CancellationToken.None + let second = inFlight.Join CancellationToken.None + Assert.False(first.IsCompleted || second.IsCompleted) + + let classification = + { + Version = VersionStamp.Create() + Text = SourceText.From "" + Lookup = Dictionary() + } + + gate.SetResult(ValueSome classification) + + Assert.Same(classification.Lookup, first.Result.Value.Lookup) + Assert.Same(classification.Lookup, second.Result.Value.Lookup) + Assert.Equal(1, computed.Value) + + [] + member _.``A shared classification is cancelled only when its last waiter leaves``() = + let gate = TaskCompletionSource() + let sharedToken = ref CancellationToken.None + + let inFlight = + InFlightClassification( + VersionStamp.Create(), + fun ct -> + sharedToken.Value <- ct + gate.Task + ) + + use first = new CancellationTokenSource() + use second = new CancellationTokenSource() + let firstJoin = inFlight.Join first.Token + let secondJoin = inFlight.Join second.Token + + first.Cancel() + + Assert.ThrowsAny(fun () -> firstJoin.GetAwaiter().GetResult() |> ignore) + |> ignore + + Assert.False(sharedToken.Value.IsCancellationRequested, "One waiter leaving must not cancel the others.") + Assert.False(secondJoin.IsCompleted) + + second.Cancel() + + Assert.ThrowsAny(fun () -> secondJoin.GetAwaiter().GetResult() |> ignore) + |> ignore + + Assert.True(sharedToken.Value.IsCancellationRequested, "The last waiter leaving must cancel the work.") + Assert.True inFlight.IsCancelled