From 6cef3e63d6d9d82f4593ed1fa52506f8e3b12d77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Saulius=20Menkevi=C4=8Dius?= Date: Sun, 13 Sep 2026 11:47:30 +0300 Subject: [PATCH 1/7] feat(completion): make suggestions from unimported namespaces configurable, default on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `csharp.completionShowItemsFromUnimportedNamespaces` to re-enable Roslyn's "show items from unimported namespaces" completion behavior, which was previously hardcoded off for performance reasons. The setting defaults to `true`; users on large solutions who hit latency issues can opt back out. 🤖 Generated with [ECA](https://eca.dev) (anthropic/claude-sonnet-5) Co-Authored-By: eca-agent --- CHANGELOG.md | 6 ++ README.md | 5 ++ .../Handlers/Completion.fs | 12 ++- src/CSharpLanguageServer/Types.fs | 5 ++ .../CompletionTests.fs | 85 +++++++++++++++++++ .../Project/TypeOnlyInUnimportedNamespace.cs | 6 ++ .../UnimportedNamespaceCompletionTests.cs | 7 ++ 7 files changed, 124 insertions(+), 2 deletions(-) create mode 100644 tests/CSharpLanguageServer.Tests/Fixtures/genericProject/Project/TypeOnlyInUnimportedNamespace.cs create mode 100644 tests/CSharpLanguageServer.Tests/Fixtures/genericProject/Project/UnimportedNamespaceCompletionTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 53a14881..7a0f4ae1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +## [Unreleased] +* Re-enable completion suggestions for types from unimported namespaces (with the missing + `using` added via `textDocument/codeAction`), gated behind the new + `csharp.completionShowItemsFromUnimportedNamespaces` setting, defaulting to `true` + - Reported in https://github.com/razzmatazz/csharp-language-server/issues/210 + ## [0.28.0] - 2026-09-13 / Klebiškis * Speed up analyzer-enabled diagnostics by sharing project-wide analyzer results across document requests for the same Roslyn solution snapshot - Reported and fixed by @alsi-lawr in https://github.com/razzmatazz/csharp-language-server/issues/403 and https://github.com/razzmatazz/csharp-language-server/pull/404 diff --git a/README.md b/README.md index 05a43fc7..5483bcac 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,11 @@ Settings are read from the `csharp` workspace configuration section - `csharp.razorSupport` - enable Razor (`.cshtml`) document support; defaults to `false` +- `csharp.completionShowItemsFromUnimportedNamespaces` - include types from + namespaces that aren't yet imported in `textDocument/completion` results + (adding the missing `using` is left to `textDocument/codeAction`); may + increase completion latency on large solutions; defaults to `true` + - `csharp.solutionPathOverride` - override the solution path to load; useful for specifying an alternative solution when multiple exist in the workspace; can also be set via the `--solution` CLI flag; defaults to `null` diff --git a/src/CSharpLanguageServer/Handlers/Completion.fs b/src/CSharpLanguageServer/Handlers/Completion.fs index c38ee88a..4b7b5456 100644 --- a/src/CSharpLanguageServer/Handlers/Completion.fs +++ b/src/CSharpLanguageServer/Handlers/Completion.fs @@ -313,9 +313,13 @@ module Completion = Microsoft.CodeAnalysis.Completion.CompletionService.GetService(doc) |> RoslynCompletionServiceWrapper + let showItemsFromUnimportedNamespaces = + context.Config.completionShowItemsFromUnimportedNamespaces + |> Option.defaultValue true + let completionOptions = RoslynCompletionOptions.Default() - |> _.WithBool("ShowItemsFromUnimportedNamespaces", false) + |> _.WithBool("ShowItemsFromUnimportedNamespaces", showItemsFromUnimportedNamespaces) |> _.WithBool("ShowNameSuggestions", false) let completionTrigger = p.Context |> codeActionContextToCompletionTrigger @@ -355,9 +359,13 @@ module Completion = Microsoft.CodeAnalysis.Completion.CompletionService.GetService(doc) |> RoslynCompletionServiceWrapper + let showItemsFromUnimportedNamespaces = + context.Config.completionShowItemsFromUnimportedNamespaces + |> Option.defaultValue true + let completionOptions = RoslynCompletionOptions.Default() - |> _.WithBool("ShowItemsFromUnimportedNamespaces", false) + |> _.WithBool("ShowItemsFromUnimportedNamespaces", showItemsFromUnimportedNamespaces) |> _.WithBool("ShowNameSuggestions", false) let completionTrigger = p.Context |> codeActionContextToCompletionTrigger diff --git a/src/CSharpLanguageServer/Types.fs b/src/CSharpLanguageServer/Types.fs index b5275808..020d257e 100644 --- a/src/CSharpLanguageServer/Types.fs +++ b/src/CSharpLanguageServer/Types.fs @@ -18,6 +18,7 @@ type CSharpConfiguration = analyzersEnabled: bool option useMetadataUris: bool option razorSupport: bool option + completionShowItemsFromUnimportedNamespaces: bool option locale: string option debug: CSharpDebugConfiguration option solutionPathOverride: string option } @@ -34,6 +35,7 @@ type CSharpConfiguration = analyzersEnabled = None useMetadataUris = None razorSupport = None + completionShowItemsFromUnimportedNamespaces = None locale = None debug = None solutionPathOverride = None } @@ -46,6 +48,9 @@ let mergeCSharpConfiguration (oldConfig: CSharpConfiguration) (newConfig: CSharp analyzersEnabled = newConfig.analyzersEnabled |> Option.orElse oldConfig.analyzersEnabled useMetadataUris = newConfig.useMetadataUris |> Option.orElse oldConfig.useMetadataUris razorSupport = newConfig.razorSupport |> Option.orElse oldConfig.razorSupport + completionShowItemsFromUnimportedNamespaces = + newConfig.completionShowItemsFromUnimportedNamespaces + |> Option.orElse oldConfig.completionShowItemsFromUnimportedNamespaces locale = newConfig.locale |> Option.orElse oldConfig.locale debug = match newConfig.debug with diff --git a/tests/CSharpLanguageServer.Tests/CompletionTests.fs b/tests/CSharpLanguageServer.Tests/CompletionTests.fs index db85ca3a..b87fd58c 100644 --- a/tests/CSharpLanguageServer.Tests/CompletionTests.fs +++ b/tests/CSharpLanguageServer.Tests/CompletionTests.fs @@ -280,3 +280,88 @@ let ``completionItem/resolve handles sentinel -1 positions in textEdit`` () = let resolved: CompletionItem = client.Request("completionItem/resolve", itemJson) Assert.That(resolved.Detail, Is.EqualTo(Some "void ClassForCompletion.MethodA(string arg)")) + +[] +let ``completion suggests types from unimported namespaces by default`` () = + // https://github.com/razzmatazz/csharp-language-server/issues/210 + use client = rentFixture "genericProject" + + use classFile = client.Open("Project/UnimportedNamespaceCompletionTests.cs") + + let completionParams: CompletionParams = + { TextDocument = { Uri = classFile.Uri } + Position = { Line = 4u; Character = 16u } + WorkDoneToken = None + PartialResultToken = None + Context = None } + + // Roslyn builds the unimported-types symbol index asynchronously in the background, + // so the first request or two may not have it ready yet; retry for a bit. + let hasUnimportedItem (completion: U2 option) = + match completion with + | Some(U2.C2 cl) -> cl.Items |> Seq.exists (fun i -> i.Label = "TypeOnlyInUnimportedNamespace") + | _ -> false + + let rec pollForCompletion attemptsLeft = + let completion: U2 option = + client.Request("textDocument/completion", completionParams) + + if hasUnimportedItem completion || attemptsLeft <= 0 then + completion + else + Thread.Sleep(500) + pollForCompletion (attemptsLeft - 1) + + let completion = pollForCompletion 20 + + match completion with + | Some(U2.C2 cl) -> + let unimportedItem = + cl.Items |> Seq.tryFind (fun i -> i.Label = "TypeOnlyInUnimportedNamespace") + + Assert.That( + unimportedItem.IsSome, + Is.True, + sprintf + "expected a completion item for 'TypeOnlyInUnimportedNamespace' from an unimported namespace. Got: %s" + (cl.Items |> Array.map (fun i -> i.Label) |> String.concat ", ") + ) + | _ -> failwith "Some U2.C2 was expected" + +[] +let ``completion does not suggest types from unimported namespaces when disabled via config`` () = + let profile = + { defaultClientProfile with + ServerConfig = + { defaultClientProfile.ServerConfig with + completionShowItemsFromUnimportedNamespaces = Some false } } + + use client = activateFixtureExt "genericProject" profile emptyFixturePatch id + + use classFile = client.Open("Project/UnimportedNamespaceCompletionTests.cs") + + let completionParams: CompletionParams = + { TextDocument = { Uri = classFile.Uri } + Position = { Line = 4u; Character = 16u } + WorkDoneToken = None + PartialResultToken = None + Context = None } + + let hasUnimportedItem (completion: U2 option) = + match completion with + | Some(U2.C2 cl) -> cl.Items |> Seq.exists (fun i -> i.Label = "TypeOnlyInUnimportedNamespace") + | _ -> false + + // Poll repeatedly (as long as the passing test above needs to see the item appear once + // Roslyn's unimported-types index has warmed up) and assert it never shows up. + for _ in 1..20 do + let completion: U2 option = + client.Request("textDocument/completion", completionParams) + + Assert.That( + hasUnimportedItem completion, + Is.False, + "did not expect a completion item for 'TypeOnlyInUnimportedNamespace' when the setting is disabled" + ) + + Thread.Sleep(500) diff --git a/tests/CSharpLanguageServer.Tests/Fixtures/genericProject/Project/TypeOnlyInUnimportedNamespace.cs b/tests/CSharpLanguageServer.Tests/Fixtures/genericProject/Project/TypeOnlyInUnimportedNamespace.cs new file mode 100644 index 00000000..b34703db --- /dev/null +++ b/tests/CSharpLanguageServer.Tests/Fixtures/genericProject/Project/TypeOnlyInUnimportedNamespace.cs @@ -0,0 +1,6 @@ +namespace Project.Unimported +{ + public class TypeOnlyInUnimportedNamespace + { + } +} diff --git a/tests/CSharpLanguageServer.Tests/Fixtures/genericProject/Project/UnimportedNamespaceCompletionTests.cs b/tests/CSharpLanguageServer.Tests/Fixtures/genericProject/Project/UnimportedNamespaceCompletionTests.cs new file mode 100644 index 00000000..57b44ee5 --- /dev/null +++ b/tests/CSharpLanguageServer.Tests/Fixtures/genericProject/Project/UnimportedNamespaceCompletionTests.cs @@ -0,0 +1,7 @@ +class UnimportedNamespaceCompletionTests +{ + public void MethodA() + { + TypeOnly + } +} From 7b378127efe4b8a2a673ae84450af0914d2ae9fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Saulius=20Menkevi=C4=8Dius?= Date: Sun, 13 Sep 2026 11:57:48 +0300 Subject: [PATCH 2/7] refactor(completion): nest completion settings under csharp.completion.* and add showNameSuggestions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reworks the flat `csharp.completionShowItemsFromUnimportedNamespaces` setting into a hierarchical `CSharpCompletionConfiguration` (mirroring the existing `csharp.debug.*` group), exposed as `csharp.completion.showItemsFromUnimportedNamespaces`. Also adds `csharp.completion.showNameSuggestions` to control Roslyn's "show name suggestions" completion behavior, defaulting to `false` to match the previous hardcoded value. 🤖 Generated with [ECA](https://eca.dev) (anthropic/claude-sonnet-5) Co-Authored-By: eca-agent --- CHANGELOG.md | 8 +- README.md | 7 +- .../Handlers/Completion.fs | 37 ++++++---- src/CSharpLanguageServer/Types.fs | 26 +++++-- .../CompletionTests.fs | 73 ++++++++++++++++++- .../Project/NameSuggestionCompletionTests.cs | 7 ++ 6 files changed, 133 insertions(+), 25 deletions(-) create mode 100644 tests/CSharpLanguageServer.Tests/Fixtures/genericProject/Project/NameSuggestionCompletionTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a0f4ae1..46e51a65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,8 +6,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [Unreleased] * Re-enable completion suggestions for types from unimported namespaces (with the missing `using` added via `textDocument/codeAction`), gated behind the new - `csharp.completionShowItemsFromUnimportedNamespaces` setting, defaulting to `true` - - Reported in https://github.com/razzmatazz/csharp-language-server/issues/210 + `csharp.completion.showItemsFromUnimportedNamespaces` setting, defaulting to `true`; also add + `csharp.completion.showNameSuggestions` to control whether `textDocument/completion` includes + suggested identifier names (e.g. `stringBuilder` for a `StringBuilder`), defaulting to `false` + and matching the previous hardcoded behavior + - Reported in https://github.com/razzmatazz/csharp-language-server/issues/210 and fixed in + https://github.com/razzmatazz/csharp-language-server/pull/425 ## [0.28.0] - 2026-09-13 / Klebiškis * Speed up analyzer-enabled diagnostics by sharing project-wide analyzer results across document requests for the same Roslyn solution snapshot diff --git a/README.md b/README.md index 5483bcac..1983b857 100644 --- a/README.md +++ b/README.md @@ -70,11 +70,16 @@ Settings are read from the `csharp` workspace configuration section - `csharp.razorSupport` - enable Razor (`.cshtml`) document support; defaults to `false` -- `csharp.completionShowItemsFromUnimportedNamespaces` - include types from +- `csharp.completion.showItemsFromUnimportedNamespaces` - include types from namespaces that aren't yet imported in `textDocument/completion` results (adding the missing `using` is left to `textDocument/codeAction`); may increase completion latency on large solutions; defaults to `true` +- `csharp.completion.showNameSuggestions` - include suggested identifier names + (e.g. `stringBuilder` when completing after `StringBuilder `) in + `textDocument/completion` results; may increase completion latency; + defaults to `false` + - `csharp.solutionPathOverride` - override the solution path to load; useful for specifying an alternative solution when multiple exist in the workspace; can also be set via the `--solution` CLI flag; defaults to `null` diff --git a/src/CSharpLanguageServer/Handlers/Completion.fs b/src/CSharpLanguageServer/Handlers/Completion.fs index 4b7b5456..851afdc1 100644 --- a/src/CSharpLanguageServer/Handlers/Completion.fs +++ b/src/CSharpLanguageServer/Handlers/Completion.fs @@ -128,6 +128,25 @@ module Completion = member __.GetDescriptionAsync(doc, item, ct) = service.GetDescriptionAsync(doc, item, ct) + /// Builds Roslyn completion options from the effective `csharp.completion.*` configuration. + /// `ShowItemsFromUnimportedNamespaces` defaults to `true` (re-enabling suggestions for + /// types whose namespace isn't imported yet — https://github.com/razzmatazz/csharp-language-server/issues/210); + /// `ShowNameSuggestions` defaults to `false` since it's rarely useful over LSP and adds latency. + let private getRoslynCompletionOptions (context: RequestContext) = + let showItemsFromUnimportedNamespaces = + context.Config.completion + |> Option.bind _.showItemsFromUnimportedNamespaces + |> Option.defaultValue true + + let showNameSuggestions = + context.Config.completion + |> Option.bind _.showNameSuggestions + |> Option.defaultValue false + + RoslynCompletionOptions.Default() + |> _.WithBool("ShowItemsFromUnimportedNamespaces", showItemsFromUnimportedNamespaces) + |> _.WithBool("ShowNameSuggestions", showNameSuggestions) + let private dynamicRegistration (cc: ClientCapabilities) = cc.TextDocument |> Option.bind _.Completion @@ -313,14 +332,7 @@ module Completion = Microsoft.CodeAnalysis.Completion.CompletionService.GetService(doc) |> RoslynCompletionServiceWrapper - let showItemsFromUnimportedNamespaces = - context.Config.completionShowItemsFromUnimportedNamespaces - |> Option.defaultValue true - - let completionOptions = - RoslynCompletionOptions.Default() - |> _.WithBool("ShowItemsFromUnimportedNamespaces", showItemsFromUnimportedNamespaces) - |> _.WithBool("ShowNameSuggestions", false) + let completionOptions = context |> getRoslynCompletionOptions let completionTrigger = p.Context |> codeActionContextToCompletionTrigger @@ -359,14 +371,7 @@ module Completion = Microsoft.CodeAnalysis.Completion.CompletionService.GetService(doc) |> RoslynCompletionServiceWrapper - let showItemsFromUnimportedNamespaces = - context.Config.completionShowItemsFromUnimportedNamespaces - |> Option.defaultValue true - - let completionOptions = - RoslynCompletionOptions.Default() - |> _.WithBool("ShowItemsFromUnimportedNamespaces", showItemsFromUnimportedNamespaces) - |> _.WithBool("ShowNameSuggestions", false) + let completionOptions = context |> getRoslynCompletionOptions let completionTrigger = p.Context |> codeActionContextToCompletionTrigger diff --git a/src/CSharpLanguageServer/Types.fs b/src/CSharpLanguageServer/Types.fs index 020d257e..601bf1ca 100644 --- a/src/CSharpLanguageServer/Types.fs +++ b/src/CSharpLanguageServer/Types.fs @@ -12,13 +12,21 @@ type CSharpDebugConfiguration = { solutionLoadDelay = None debugMode = None } +type CSharpCompletionConfiguration = + { showItemsFromUnimportedNamespaces: bool option + showNameSuggestions: bool option } + + static member Default = + { showItemsFromUnimportedNamespaces = None + showNameSuggestions = None } + type CSharpConfiguration = { logLevel: string option applyFormattingOptions: bool option analyzersEnabled: bool option useMetadataUris: bool option razorSupport: bool option - completionShowItemsFromUnimportedNamespaces: bool option + completion: CSharpCompletionConfiguration option locale: string option debug: CSharpDebugConfiguration option solutionPathOverride: string option } @@ -35,7 +43,7 @@ type CSharpConfiguration = analyzersEnabled = None useMetadataUris = None razorSupport = None - completionShowItemsFromUnimportedNamespaces = None + completion = None locale = None debug = None solutionPathOverride = None } @@ -48,9 +56,17 @@ let mergeCSharpConfiguration (oldConfig: CSharpConfiguration) (newConfig: CSharp analyzersEnabled = newConfig.analyzersEnabled |> Option.orElse oldConfig.analyzersEnabled useMetadataUris = newConfig.useMetadataUris |> Option.orElse oldConfig.useMetadataUris razorSupport = newConfig.razorSupport |> Option.orElse oldConfig.razorSupport - completionShowItemsFromUnimportedNamespaces = - newConfig.completionShowItemsFromUnimportedNamespaces - |> Option.orElse oldConfig.completionShowItemsFromUnimportedNamespaces + completion = + match newConfig.completion with + | Some newCompletion -> + Some + { showItemsFromUnimportedNamespaces = + newCompletion.showItemsFromUnimportedNamespaces + |> Option.orElse (oldConfig.completion |> Option.bind _.showItemsFromUnimportedNamespaces) + showNameSuggestions = + newCompletion.showNameSuggestions + |> Option.orElse (oldConfig.completion |> Option.bind _.showNameSuggestions) } + | None -> oldConfig.completion locale = newConfig.locale |> Option.orElse oldConfig.locale debug = match newConfig.debug with diff --git a/tests/CSharpLanguageServer.Tests/CompletionTests.fs b/tests/CSharpLanguageServer.Tests/CompletionTests.fs index b87fd58c..6984b285 100644 --- a/tests/CSharpLanguageServer.Tests/CompletionTests.fs +++ b/tests/CSharpLanguageServer.Tests/CompletionTests.fs @@ -7,6 +7,7 @@ open NUnit.Framework open Ionide.LanguageServerProtocol.Types open Ionide.LanguageServerProtocol.Server +open CSharpLanguageServer.Types open CSharpLanguageServer.Tests.Tooling open CSharpLanguageServer.Tests.Fixtures @@ -334,7 +335,10 @@ let ``completion does not suggest types from unimported namespaces when disabled { defaultClientProfile with ServerConfig = { defaultClientProfile.ServerConfig with - completionShowItemsFromUnimportedNamespaces = Some false } } + completion = + Some + { CSharpCompletionConfiguration.Default with + showItemsFromUnimportedNamespaces = Some false } } } use client = activateFixtureExt "genericProject" profile emptyFixturePatch id @@ -365,3 +369,70 @@ let ``completion does not suggest types from unimported namespaces when disabled ) Thread.Sleep(500) + +[] +let ``completion does not suggest names by default`` () = + use client = rentFixture "genericProject" + + use classFile = client.Open("Project/NameSuggestionCompletionTests.cs") + + let completionParams: CompletionParams = + { TextDocument = { Uri = classFile.Uri } + Position = { Line = 4u; Character = 34u } + WorkDoneToken = None + PartialResultToken = None + Context = None } + + let completion: U2 option = + client.Request("textDocument/completion", completionParams) + + match completion with + | Some(U2.C2 cl) -> + let nameSuggestionItem = + cl.Items |> Seq.tryFind (fun i -> i.Label = "stringBuilder") + + Assert.That( + nameSuggestionItem.IsNone, + Is.True, + "did not expect a name-suggestion completion item 'stringBuilder' by default" + ) + | _ -> failwith "Some U2.C2 was expected" + +[] +let ``completion suggests names when enabled via config`` () = + let profile = + { defaultClientProfile with + ServerConfig = + { defaultClientProfile.ServerConfig with + completion = + Some + { CSharpCompletionConfiguration.Default with + showNameSuggestions = Some true } } } + + use client = activateFixtureExt "genericProject" profile emptyFixturePatch id + + use classFile = client.Open("Project/NameSuggestionCompletionTests.cs") + + let completionParams: CompletionParams = + { TextDocument = { Uri = classFile.Uri } + Position = { Line = 4u; Character = 34u } + WorkDoneToken = None + PartialResultToken = None + Context = None } + + let completion: U2 option = + client.Request("textDocument/completion", completionParams) + + match completion with + | Some(U2.C2 cl) -> + let nameSuggestionItem = + cl.Items |> Seq.tryFind (fun i -> i.Label = "stringBuilder") + + Assert.That( + nameSuggestionItem.IsSome, + Is.True, + sprintf + "expected a name-suggestion completion item 'stringBuilder'. Got: %s" + (cl.Items |> Array.map (fun i -> i.Label) |> String.concat ", ") + ) + | _ -> failwith "Some U2.C2 was expected" diff --git a/tests/CSharpLanguageServer.Tests/Fixtures/genericProject/Project/NameSuggestionCompletionTests.cs b/tests/CSharpLanguageServer.Tests/Fixtures/genericProject/Project/NameSuggestionCompletionTests.cs new file mode 100644 index 00000000..274381eb --- /dev/null +++ b/tests/CSharpLanguageServer.Tests/Fixtures/genericProject/Project/NameSuggestionCompletionTests.cs @@ -0,0 +1,7 @@ +class NameSuggestionCompletionTests +{ + public void MethodA() + { + System.Text.StringBuilder + } +} From cfc9ddb48aad726a45e37a983f8f19a0b8f30d03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Saulius=20Menkevi=C4=8Dius?= Date: Sun, 13 Sep 2026 12:23:09 +0300 Subject: [PATCH 3/7] feat(completion): insert missing using when accepting unimported-namespace completion item MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renames `csharp.completion.showItemsFromUnimportedNamespaces` to `csharp.completion.completeUnimportedTypes` to reflect that it now controls both aspects together: showing the item in the completion list *and* inserting the missing `using` (as `additionalTextEdits`) when the item is accepted, instead of leaving the import to be added separately via `textDocument/codeAction`. Roslyn already marks such items `IsComplexTextEdit = true`; on `completionItem/resolve` we now call `CompletionService.GetChangeAsync` for those items and forward every resulting `TextChange` other than the item's own in-place replacement as `AdditionalTextEdits`. Plain (non-complex) completion items are unaffected. 🤖 Generated with [ECA](https://eca.dev) (anthropic/claude-sonnet-5) Co-Authored-By: eca-agent --- CHANGELOG.md | 6 +- README.md | 9 +-- .../Handlers/Completion.fs | 54 +++++++++++++++-- src/CSharpLanguageServer/Types.fs | 10 ++-- .../CompletionTests.fs | 58 ++++++++++++++++++- 5 files changed, 119 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 46e51a65..2ec8e6d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,9 +4,9 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [Unreleased] -* Re-enable completion suggestions for types from unimported namespaces (with the missing - `using` added via `textDocument/codeAction`), gated behind the new - `csharp.completion.showItemsFromUnimportedNamespaces` setting, defaulting to `true`; also add +* Re-enable completion suggestions for types from unimported namespaces, now inserting the + missing `using` (via `additionalTextEdits`) when one of those items is accepted, gated behind + the new `csharp.completion.completeUnimportedTypes` setting, defaulting to `true`; also add `csharp.completion.showNameSuggestions` to control whether `textDocument/completion` includes suggested identifier names (e.g. `stringBuilder` for a `StringBuilder`), defaulting to `false` and matching the previous hardcoded behavior diff --git a/README.md b/README.md index 1983b857..e28f6816 100644 --- a/README.md +++ b/README.md @@ -70,10 +70,11 @@ Settings are read from the `csharp` workspace configuration section - `csharp.razorSupport` - enable Razor (`.cshtml`) document support; defaults to `false` -- `csharp.completion.showItemsFromUnimportedNamespaces` - include types from - namespaces that aren't yet imported in `textDocument/completion` results - (adding the missing `using` is left to `textDocument/codeAction`); may - increase completion latency on large solutions; defaults to `true` +- `csharp.completion.completeUnimportedTypes` - include types from namespaces + that aren't yet imported in `textDocument/completion` results, inserting the + missing `using` (via `additionalTextEdits`) when one of those items is + accepted; may increase completion latency on large solutions; defaults to + `true` - `csharp.completion.showNameSuggestions` - include suggested identifier names (e.g. `stringBuilder` when completing after `StringBuilder `) in diff --git a/src/CSharpLanguageServer/Handlers/Completion.fs b/src/CSharpLanguageServer/Handlers/Completion.fs index 851afdc1..2d846daf 100644 --- a/src/CSharpLanguageServer/Handlers/Completion.fs +++ b/src/CSharpLanguageServer/Handlers/Completion.fs @@ -130,12 +130,16 @@ module Completion = /// Builds Roslyn completion options from the effective `csharp.completion.*` configuration. /// `ShowItemsFromUnimportedNamespaces` defaults to `true` (re-enabling suggestions for - /// types whose namespace isn't imported yet — https://github.com/razzmatazz/csharp-language-server/issues/210); + /// types whose namespace isn't imported yet — https://github.com/razzmatazz/csharp-language-server/issues/210). + /// Selecting one of those items commits as a complex text edit (see `IsComplexTextEdit` below) + /// that both inserts the type name and adds the missing `using` — `completeUnimportedTypes` + /// controls both behaviors together, since offering an item that doesn't compile without an + /// import the user has to add by hand isn't very useful on its own. /// `ShowNameSuggestions` defaults to `false` since it's rarely useful over LSP and adds latency. let private getRoslynCompletionOptions (context: RequestContext) = - let showItemsFromUnimportedNamespaces = + let completeUnimportedTypes = context.Config.completion - |> Option.bind _.showItemsFromUnimportedNamespaces + |> Option.bind _.completeUnimportedTypes |> Option.defaultValue true let showNameSuggestions = @@ -144,7 +148,7 @@ module Completion = |> Option.defaultValue false RoslynCompletionOptions.Default() - |> _.WithBool("ShowItemsFromUnimportedNamespaces", showItemsFromUnimportedNamespaces) + |> _.WithBool("ShowItemsFromUnimportedNamespaces", completeUnimportedTypes) |> _.WithBool("ShowNameSuggestions", showNameSuggestions) let private dynamicRegistration (cc: ClientCapabilities) = @@ -439,6 +443,42 @@ module Completion = LspWorkspaceUpdate.Empty } + /// For completion items that need more than inserting `DisplayText` at their original + /// `Span` on commit (`IsComplexTextEdit`) — e.g. an item from an unimported namespace, + /// which also needs a `using` inserted at the top of the file — resolves the full set of + /// changes Roslyn would make and returns everything other than the primary in-place edit + /// as LSP `AdditionalTextEdits`, so accepting the item also adds the missing import. + let private getAdditionalTextEditsForComplexItem + (completionService: Microsoft.CodeAnalysis.Completion.CompletionService) + (doc: Document) + (item: Microsoft.CodeAnalysis.Completion.CompletionItem) + (ct: System.Threading.CancellationToken) + : Async = + async { + if not item.IsComplexTextEdit then + return None + else + let! change = + completionService.GetChangeAsync(doc, item, System.Nullable(), ct) + |> Async.AwaitTask + + if change.TextChanges.IsDefaultOrEmpty then + return None + else + let! sourceText = doc.GetTextAsync(ct) |> Async.AwaitTask + + let additionalEdits = + change.TextChanges + |> Seq.filter (fun tc -> tc.Span <> item.Span) + |> Seq.map (TextEdit.fromTextChange sourceText.Lines) + |> Array.ofSeq + + return + match additionalEdits with + | [||] -> None + | edits -> Some edits + } + let resolve (_context: RequestContext) (item: CompletionItem) @@ -475,10 +515,14 @@ module Completion = Value = d } |> U2.C2) + let! additionalTextEdits = + getAdditionalTextEditsForComplexItem completionService doc roslynCompletionItem ct + return { item with Detail = synopsis - Documentation = updatedItemDocumentation } + Documentation = updatedItemDocumentation + AdditionalTextEdits = additionalTextEdits |> Option.orElse item.AdditionalTextEdits } |> LspResult.success, LspWorkspaceUpdate.Empty diff --git a/src/CSharpLanguageServer/Types.fs b/src/CSharpLanguageServer/Types.fs index 601bf1ca..86dc48ef 100644 --- a/src/CSharpLanguageServer/Types.fs +++ b/src/CSharpLanguageServer/Types.fs @@ -13,11 +13,11 @@ type CSharpDebugConfiguration = debugMode = None } type CSharpCompletionConfiguration = - { showItemsFromUnimportedNamespaces: bool option + { completeUnimportedTypes: bool option showNameSuggestions: bool option } static member Default = - { showItemsFromUnimportedNamespaces = None + { completeUnimportedTypes = None showNameSuggestions = None } type CSharpConfiguration = @@ -60,9 +60,9 @@ let mergeCSharpConfiguration (oldConfig: CSharpConfiguration) (newConfig: CSharp match newConfig.completion with | Some newCompletion -> Some - { showItemsFromUnimportedNamespaces = - newCompletion.showItemsFromUnimportedNamespaces - |> Option.orElse (oldConfig.completion |> Option.bind _.showItemsFromUnimportedNamespaces) + { completeUnimportedTypes = + newCompletion.completeUnimportedTypes + |> Option.orElse (oldConfig.completion |> Option.bind _.completeUnimportedTypes) showNameSuggestions = newCompletion.showNameSuggestions |> Option.orElse (oldConfig.completion |> Option.bind _.showNameSuggestions) } diff --git a/tests/CSharpLanguageServer.Tests/CompletionTests.fs b/tests/CSharpLanguageServer.Tests/CompletionTests.fs index 6984b285..229250da 100644 --- a/tests/CSharpLanguageServer.Tests/CompletionTests.fs +++ b/tests/CSharpLanguageServer.Tests/CompletionTests.fs @@ -329,6 +329,62 @@ let ``completion suggests types from unimported namespaces by default`` () = ) | _ -> failwith "Some U2.C2 was expected" +[] +let ``completion inserts using directive when accepting an unimported-namespace item`` () = + // https://github.com/razzmatazz/csharp-language-server/issues/210 + use client = rentFixture "genericProject" + + use classFile = client.Open("Project/UnimportedNamespaceCompletionTests.cs") + + let completionParams: CompletionParams = + { TextDocument = { Uri = classFile.Uri } + Position = { Line = 4u; Character = 16u } + WorkDoneToken = None + PartialResultToken = None + Context = None } + + let hasUnimportedItem (completion: U2 option) = + match completion with + | Some(U2.C2 cl) -> cl.Items |> Seq.exists (fun i -> i.Label = "TypeOnlyInUnimportedNamespace") + | _ -> false + + let rec pollForCompletion attemptsLeft = + let completion: U2 option = + client.Request("textDocument/completion", completionParams) + + if hasUnimportedItem completion || attemptsLeft <= 0 then + completion + else + Thread.Sleep(500) + pollForCompletion (attemptsLeft - 1) + + let completion = pollForCompletion 20 + + let item = + match completion with + | Some(U2.C2 cl) -> cl.Items |> Seq.find (fun i -> i.Label = "TypeOnlyInUnimportedNamespace") + | _ -> failwith "Some U2.C2 was expected" + + let resolved: CompletionItem = client.Request("completionItem/resolve", item) + + // The primary insertion is still done via `InsertText` (no `TextEdit`), unaffected by this change. + Assert.That(resolved.InsertText, Is.EqualTo(Some "TypeOnlyInUnimportedNamespace")) + Assert.That(resolved.TextEdit.IsSome, Is.False) + + match resolved.AdditionalTextEdits with + | Some edits -> + let hasUsingEdit = + edits |> Array.exists (fun e -> e.NewText.Contains("using Project.Unimported;")) + + Assert.That( + hasUsingEdit, + Is.True, + sprintf + "expected an additional text edit inserting 'using Project.Unimported;'. Got: %s" + (edits |> Array.map (fun e -> e.NewText) |> String.concat " | ") + ) + | None -> failwith "expected AdditionalTextEdits to be Some when accepting an unimported-namespace item" + [] let ``completion does not suggest types from unimported namespaces when disabled via config`` () = let profile = @@ -338,7 +394,7 @@ let ``completion does not suggest types from unimported namespaces when disabled completion = Some { CSharpCompletionConfiguration.Default with - showItemsFromUnimportedNamespaces = Some false } } } + completeUnimportedTypes = Some false } } } use client = activateFixtureExt "genericProject" profile emptyFixturePatch id From cb1a5b4a9e3df1bbb9fb0c582e184b47c1633459 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Saulius=20Menkevi=C4=8Dius?= Date: Sun, 13 Sep 2026 16:04:39 +0300 Subject: [PATCH 4/7] docs(changelog): add GitHub handles to Unreleased entry --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ec8e6d5..4c7ac8a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,8 +10,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). `csharp.completion.showNameSuggestions` to control whether `textDocument/completion` includes suggested identifier names (e.g. `stringBuilder` for a `StringBuilder`), defaulting to `false` and matching the previous hardcoded behavior - - Reported in https://github.com/razzmatazz/csharp-language-server/issues/210 and fixed in - https://github.com/razzmatazz/csharp-language-server/pull/425 + - Reported by @kervanaslan in https://github.com/razzmatazz/csharp-language-server/issues/210 and + fixed by @razzmatazz in https://github.com/razzmatazz/csharp-language-server/pull/425 ## [0.28.0] - 2026-09-13 / Klebiškis * Speed up analyzer-enabled diagnostics by sharing project-wide analyzer results across document requests for the same Roslyn solution snapshot From 40f0dd6deecf617a3c214f1f0c3366a273a3e747 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Saulius=20Menkevi=C4=8Dius?= Date: Sun, 13 Sep 2026 16:08:04 +0300 Subject: [PATCH 5/7] docs(agents): inline Conventional Commits format in AGENTS.md State the `(): ` scheme directly instead of only linking to CONTRIBUTING.md#code-style, so it isn't missed when committing. --- AGENTS.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 6151e552..f7061f8b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,7 +55,11 @@ There is no meaningful performance win from skipping the build step. See [CONTRIBUTING.md](CONTRIBUTING.md#code-style). -Commit messages should be a short single-line summary — no bullet lists of individual changes. +Commit messages use [Conventional Commits](https://www.conventionalcommits.org/) style — +`(): ` (e.g. `fix(completion): avoid NullReferenceException on +empty import list`) — as a short single-line summary, no bullet lists of individual changes. +See [CONTRIBUTING.md#commit-messages](CONTRIBUTING.md#commit-messages) for the full list of +`type`s and more examples. ## Key Docs From a1111b0cccf2ffd6ad3dd1fa77713513f228bbfa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Saulius=20Menkevi=C4=8Dius?= Date: Sun, 13 Sep 2026 16:54:15 +0300 Subject: [PATCH 6/7] docs(agents): anonymize private code in plans, prose, and examples Plans and other persisted docs sometimes draw on a real session against a user's private solution; capture the standing rule to swap in fictional namespaces/types/file names before committing, keeping this project's own public identifiers exempt. --- AGENTS.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index f7061f8b..1437bac1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,6 +51,19 @@ The `--no-build` flag skips recompiling the test project, which means type error code are silently ignored and the stale binary from a previous build is used instead. There is no meaningful performance win from skipping the build step. +## Anonymize private code in plans, prose, and examples + +When writing anything that will be committed or otherwise persisted (`plans/*.md`, +design docs, PR descriptions, code comments) and it draws on a real session against a +user's private solution — namespaces, type names, file names, log excerpts — replace +those with fictional equivalents (e.g. `Fabrikam.Processing`, `ExchangeRateSource`, +`MiscDataCleanupHandlers.cs`) before committing. Keep the *shape* of the example intact +(same structure, same character offsets/prefixes where they matter to the point being +made) so the technical content is unaffected — only the identifying names change. This +does not apply to this project's own public identifiers (namespaces under +`CSharpLanguageServer.*`, file paths under `src/`, GitHub issue/PR numbers on this repo) +since those aren't private. + ## Code Style & Commit Conventions See [CONTRIBUTING.md](CONTRIBUTING.md#code-style). From 9ba6135c7424a080426edbfb8669edab2eafb772 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Saulius=20Menkevi=C4=8Dius?= Date: Sun, 13 Sep 2026 17:08:17 +0300 Subject: [PATCH 7/7] fix(completion): stop reporting textDocument/completion as incomplete Roslyn's CompletionList has no incompleteness concept of its own (verified by reflecting over the actual Microsoft.CodeAnalysis.Features assembly this project builds against - no member resembling "there's more, ask again") - it always returns its full candidate set for the current cursor context in one call, expecting the client to filter by filterText as the user keeps typing. Hardcoding IsIncomplete = true here instead made editors re-request the full list - including the unimported-namespace scan - via TriggerForIncompleteCompletions on every keystroke, turning one legitimate completion session into a fresh multi-megabyte recomputation per character typed on large solutions. Update CompletionTests.fs's assertion, which previously codified the old (incorrect) behavior. --- CHANGELOG.md | 4 ++++ src/CSharpLanguageServer/Handlers/Completion.fs | 9 ++++++++- tests/CSharpLanguageServer.Tests/CompletionTests.fs | 2 +- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c7ac8a6..dc0720de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). and matching the previous hardcoded behavior - Reported by @kervanaslan in https://github.com/razzmatazz/csharp-language-server/issues/210 and fixed by @razzmatazz in https://github.com/razzmatazz/csharp-language-server/pull/425 +* Stop reporting `textDocument/completion` results as incomplete, which was forcing editors to + re-request the full completion list (including the unimported-namespace scan above) on every + keystroke instead of filtering the already-returned list locally; this could produce a large, + redundant `textDocument/completion` response per character typed on large solutions ## [0.28.0] - 2026-09-13 / Klebiškis * Speed up analyzer-enabled diagnostics by sharing project-wide analyzer results across document requests for the same Roslyn solution snapshot diff --git a/src/CSharpLanguageServer/Handlers/Completion.fs b/src/CSharpLanguageServer/Handlers/Completion.fs index 2d846daf..1fa2c47d 100644 --- a/src/CSharpLanguageServer/Handlers/Completion.fs +++ b/src/CSharpLanguageServer/Handlers/Completion.fs @@ -434,7 +434,14 @@ module Completion = lspCompletionItemsWithCacheInfo |> Array.map (fun (item, _, _, _) -> item) return - { IsIncomplete = true + // Roslyn's `CompletionList` has no concept of "there's more, ask again" — it + // always returns its full candidate set for the current cursor context in one + // call, unfiltered by the typed prefix (LSP expects the client to filter + // `items` locally using `filterText` as the user keeps typing). Reporting + // `IsIncomplete = true` here would instead make the client re-request on every + // keystroke via `TriggerForIncompleteCompletions`, redoing this same + // (potentially expensive, e.g. unimported-namespace) computation each time. + { IsIncomplete = false Items = items ItemDefaults = None } |> U2.C2 diff --git a/tests/CSharpLanguageServer.Tests/CompletionTests.fs b/tests/CSharpLanguageServer.Tests/CompletionTests.fs index 229250da..995391d1 100644 --- a/tests/CSharpLanguageServer.Tests/CompletionTests.fs +++ b/tests/CSharpLanguageServer.Tests/CompletionTests.fs @@ -39,7 +39,7 @@ let ``completion works in a .cs file`` () = match completion0 with | Some(U2.C2 cl) -> - Assert.That(cl.IsIncomplete, Is.True) + Assert.That(cl.IsIncomplete, Is.False) Assert.That(cl.ItemDefaults.IsSome, Is.False) Assert.That(cl.Items.Length, Is.EqualTo(6))