From c4034c640695fabfa4383c81f5cd81b0e5a10e40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Saulius=20Menkevi=C4=8Dius?= Date: Fri, 5 Jun 2026 11:32:06 +0300 Subject: [PATCH 1/4] Implement LSPAny type as a wrapper for JToken, implement structural equality This is to enable mechanical conversion of the codebase to support System.Text.Json by replacing JToken with JsonElement. --- src/Client.fs | 6 +-- src/Types.cg.fs | 7 --- src/Types.fs | 53 ++++++++++++++++++++++- tests/Benchmarks.fs | 2 +- tests/Tests.fs | 12 +++-- tools/MetaModelGenerator/GenerateTypes.fs | 11 ++--- 6 files changed, 66 insertions(+), 25 deletions(-) diff --git a/src/Client.fs b/src/Client.fs index c828e5c..3c60e89 100644 --- a/src/Client.fs +++ b/src/Client.fs @@ -42,7 +42,7 @@ type LspClient() = /// The telemetry notification is sent from the server to the client to ask the client to log /// a telemetry event. - abstract member TelemetryEvent: Newtonsoft.Json.Linq.JToken -> Async + abstract member TelemetryEvent: LSPAny -> Async default __.TelemetryEvent(_) = ignoreNotification @@ -81,7 +81,7 @@ type LspClient() = /// The request can fetch n configuration settings in one roundtrip. The order of the returned configuration /// settings correspond to the order of the passed ConfigurationItems (e.g. the first item in the response /// is the result for the first configuration item in the params). - abstract member WorkspaceConfiguration: ConfigurationParams -> AsyncLspResult + abstract member WorkspaceConfiguration: ConfigurationParams -> AsyncLspResult default __.WorkspaceConfiguration(_) = notImplemented @@ -172,7 +172,7 @@ type LspClient() = member this.WindowShowMessageRequest(p: ShowMessageRequestParams) = this.WindowShowMessageRequest(p) member this.WindowLogMessage(p: LogMessageParams) = this.WindowLogMessage(p) member this.WindowShowDocument(p: ShowDocumentParams) = this.WindowShowDocument(p) - member this.TelemetryEvent(p: Newtonsoft.Json.Linq.JToken) = this.TelemetryEvent(p) + member this.TelemetryEvent(p: LSPAny) = this.TelemetryEvent(p) member this.ClientRegisterCapability(p: RegistrationParams) = this.ClientRegisterCapability(p) member this.ClientUnregisterCapability(p: UnregistrationParams) = this.ClientUnregisterCapability(p) member this.WorkspaceWorkspaceFolders() = this.WorkspaceWorkspaceFolders() diff --git a/src/Types.cg.fs b/src/Types.cg.fs index 3ac9317..2f2023a 100644 --- a/src/Types.cg.fs +++ b/src/Types.cg.fs @@ -6438,13 +6438,6 @@ type DefinitionLink = LocationLink /// LSP arrays. /// @since 3.17.0 type LSPArray = LSPAny[] -/// The LSP any type. -/// Please note that strictly speaking a property with the value `undefined` -/// can't be converted into JSON preserving the property name. However for -/// convenience it is allowed and assumed that all these properties are -/// optional as well. -/// @since 3.17.0 -type LSPAny = JToken /// The declaration of a symbol representation as one or many {@link Location locations}. type Declaration = U2 /// Information about where a symbol is declared. diff --git a/src/Types.fs b/src/Types.fs index 47106c1..ee9b7c9 100644 --- a/src/Types.fs +++ b/src/Types.fs @@ -1,7 +1,8 @@ namespace Ionide.LanguageServerProtocol.Types open Ionide.LanguageServerProtocol - +open Newtonsoft.Json +open Newtonsoft.Json.Linq /// Types in typescript can have hardcoded values for their fields, this attribute is used to mark /// the default value for a field in a type and is used when deserializing the type to json @@ -89,4 +90,52 @@ type U4<'T1, 'T2, 'T3, 'T4> = | C1 c -> string c | C2 c -> string c | C3 c -> string 3 - | C4 c -> string 3 \ No newline at end of file + | C4 c -> string 3 + +/// The LSP any type. +/// +/// Wraps a and provides structural equality and hashing so that values +/// can safely be used in sets, maps, and comparisons. +/// +/// Note: structural equality and hashing are implemented here explicitly because while +/// does provide structural equality for primitive values, +/// and +/// have a known-broken GetHashCode that can return different values for structurally equal +/// instances. System.Text.Json.JsonElement — the intended future backing type — provides +/// neither structural equality nor hashing at all. This wrapper is therefore necessary regardless +/// of which JSON library is used underneath. +/// +/// The internal representation is intentionally kept behind a single JToken property so +/// that the backing type can be swapped to System.Text.Json.JsonElement in the future +/// with minimal impact on call sites. +[)>] +type LSPAny(token: JToken) = + + /// The underlying JSON token. + member _.JToken: JToken = token + + override _.ToString() = token.ToString(Formatting.None) + + override _.GetHashCode() = + // JToken does not override GetHashCode, so we compute one from the raw JSON text. + // This is consistent with the Equals implementation below (same raw text ↔ same hash). + token.ToString(Formatting.None).GetHashCode() + + override x.Equals(obj) = + match obj with + | :? LSPAny as other -> JToken.DeepEquals(token, other.JToken) + | _ -> false + + interface System.IEquatable with + member x.Equals(other) = JToken.DeepEquals(token, other.JToken) + +/// Newtonsoft.Json converter for . +/// Reads any JSON value into a and wraps it; writes by delegating to the token. +and LSPAnyConverter() = + inherit JsonConverter() + + override _.CanConvert(t) = t = typeof + + override _.ReadJson(reader, _t, _existing, _serializer) = LSPAny(JToken.ReadFrom(reader)) :> obj + + override _.WriteJson(writer, value, _serializer) = (value :?> LSPAny).JToken.WriteTo(writer) \ No newline at end of file diff --git a/tests/Benchmarks.fs b/tests/Benchmarks.fs index 7769572..9a22b47 100644 --- a/tests/Benchmarks.fs +++ b/tests/Benchmarks.fs @@ -679,7 +679,7 @@ type MultipleTypesBenchmarks() = Tooltip = Some(U2.C2 { Kind = MarkupKind.PlainText; Value = "some tooltip" }) PaddingLeft = Some true PaddingRight = Some false - Data = Some(JToken.FromObject "some data") + Data = Some(LSPAny(JToken.FromObject "some data")) } let allLsp: obj[] = [| diff --git a/tests/Tests.fs b/tests/Tests.fs index 6fa1953..0b7033d 100644 --- a/tests/Tests.fs +++ b/tests/Tests.fs @@ -919,14 +919,12 @@ let private serializationTests = Tooltip = Some(U2.C1 "tooltipping") PaddingLeft = Some true PaddingRight = Some false - Data = Some(JToken.FromObject "some data") + Data = Some(LSPAny(JToken.FromObject "some data")) } testThereAndBackAgain theInlayHint - testCase "can keep Data with JToken" + testCase "can keep Data with LSPAny" <| fun _ -> - // JToken doesn't use structural equality - // -> Expecto equal check fails even when same content in complex JToken let data = { InlayHintData.TextDocument = { Uri = "..." } Range = { Start = { Line = 5u; Character = 7u }; End = { Line = 5u; Character = 10u } } @@ -940,14 +938,14 @@ let private serializationTests = Tooltip = None PaddingLeft = None PaddingRight = None - Data = Some(JToken.FromObject data) + Data = Some(LSPAny(JToken.FromObject data)) } let output = thereAndBackAgain theInlayHint let outputData = output.Data - |> Option.map (fun t -> t.ToObject()) + |> Option.map (fun x -> x.JToken.ToObject()) Expect.equal outputData (Some data) "Data should not change" testCase "can roundtrip InlayHint with all fields (complex)" @@ -996,7 +994,7 @@ let private serializationTests = Tooltip = Some(U2.C2 { Kind = MarkupKind.PlainText; Value = "some tooltip" }) PaddingLeft = Some true PaddingRight = Some false - Data = Some(JToken.FromObject "some data") + Data = Some(LSPAny(JToken.FromObject "some data")) } testThereAndBackAgain theInlayHint diff --git a/tools/MetaModelGenerator/GenerateTypes.fs b/tools/MetaModelGenerator/GenerateTypes.fs index ad13d75..1b7c733 100644 --- a/tools/MetaModelGenerator/GenerateTypes.fs +++ b/tools/MetaModelGenerator/GenerateTypes.fs @@ -797,11 +797,12 @@ module GenerateTypes = o } - AnonymousModule() { - - abbrev - - } + // LSPAny is defined as a proper wrapper class (with structural equality) in Types.fs. + // Do not emit a generated alias for it here. + if alias.Name = "LSPAny" then + AnonymousModule() { () } + else + AnonymousModule() { abbrev } /// Creates Open or Closed Enums based on an Enumeration From f958c207f9d82a271f294bafaebe62dd93e57c06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Saulius=20Menkevi=C4=8Dius?= Date: Fri, 5 Jun 2026 11:46:39 +0300 Subject: [PATCH 2/4] Update serialize/deserialize to produce LSPAny, update tests --- src/LanguageServerProtocol.fs | 7 +++-- tests/Benchmarks.fs | 10 +++---- tests/Tests.fs | 54 +++++++++++++++++++++++------------ 3 files changed, 45 insertions(+), 26 deletions(-) diff --git a/src/LanguageServerProtocol.fs b/src/LanguageServerProtocol.fs index fdfb11e..5dd468f 100644 --- a/src/LanguageServerProtocol.fs +++ b/src/LanguageServerProtocol.fs @@ -35,8 +35,11 @@ module Server = let jsonRpcFormatter = defaultJsonRpcFormatter () - let deserialize<'t> (token: JToken) = token.ToObject<'t>(jsonRpcFormatter.JsonSerializer) - let serialize<'t> (o: 't) = JToken.FromObject(o, jsonRpcFormatter.JsonSerializer) + let deserialize<'t> (value: LSPAny) = value.JToken.ToObject<'t>(jsonRpcFormatter.JsonSerializer) + + let serialize<'t> (o: 't) = + JToken.FromObject(o, jsonRpcFormatter.JsonSerializer) + |> LSPAny let requestHandling<'param, 'result> (run: 'param -> AsyncLspResult<'result>) : Delegate = let runAsTask param ct = diff --git a/tests/Benchmarks.fs b/tests/Benchmarks.fs index 9a22b47..e578e5d 100644 --- a/tests/Benchmarks.fs +++ b/tests/Benchmarks.fs @@ -707,7 +707,7 @@ type MultipleTypesBenchmarks() = inlayHint |> serialize - let res = json.ToObject(o.GetType(), jsonRpcFormatter.JsonSerializer) + let res = json.JToken.ToObject(o.GetType(), jsonRpcFormatter.JsonSerializer) () [] @@ -722,7 +722,7 @@ type MultipleTypesBenchmarks() = example |> serialize - let res = json.ToObject(example.GetType(), jsonRpcFormatter.JsonSerializer) + let res = json.JToken.ToObject(example.GetType(), jsonRpcFormatter.JsonSerializer) () [] @@ -741,7 +741,7 @@ type MultipleTypesBenchmarks() = option |> serialize - let _ = json.ToObject(option.GetType(), jsonRpcFormatter.JsonSerializer) + let _ = json.JToken.ToObject(option.GetType(), jsonRpcFormatter.JsonSerializer) () member _.SingleCaseUnion_ArgumentsSource() = @@ -762,7 +762,7 @@ type MultipleTypesBenchmarks() = data |> serialize - let _ = json.ToObject(typeof, jsonRpcFormatter.JsonSerializer) + let _ = json.JToken.ToObject(typeof, jsonRpcFormatter.JsonSerializer) () member _.ErasedUnion_ArgumentsSource() = @@ -791,7 +791,7 @@ type MultipleTypesBenchmarks() = data |> serialize - let _ = json.ToObject(typeof, jsonRpcFormatter.JsonSerializer) + let _ = json.JToken.ToObject(typeof, jsonRpcFormatter.JsonSerializer) () diff --git a/tests/Tests.fs b/tests/Tests.fs index 0b7033d..e64617c 100644 --- a/tests/Tests.fs +++ b/tests/Tests.fs @@ -99,9 +99,9 @@ let private serializationTests = let mkLower (str: string) = sprintf "%c%s" (Char.ToLowerInvariant str[0]) (str.Substring(1)) /// Note: changes first letter into lower case - let removeProperty (name: string) (json: JToken) = + let removeProperty (name: string) (json: LSPAny) = let prop = - (json :?> JObject) + (json.JToken :?> JObject) .Property( name |> mkLower @@ -111,8 +111,8 @@ let private serializationTests = json /// Note: changes first letter into lower case - let addProperty (name: string) (value: 'a) (json: JToken) = - let jObj = json :?> JObject + let addProperty (name: string) (value: 'a) (json: LSPAny) = + let jObj = json.JToken :?> JObject jObj.Add( JProperty( @@ -124,8 +124,8 @@ let private serializationTests = json - let tryGetProperty (name: string) (json: JToken) = - let jObj = json :?> JObject + let tryGetProperty (name: string) (json: LSPAny) = + let jObj = json.JToken :?> JObject jObj.Property( name @@ -133,7 +133,7 @@ let private serializationTests = ) |> Option.ofObj - let logJson (json: JToken) = + let logJson (json: LSPAny) = printfn $"%s{json.ToString()}" json @@ -160,7 +160,7 @@ let private serializationTests = let json = o |> serialize - :?> JObject + |> fun a -> a.JToken :?> JObject let props = json.Properties() @@ -241,7 +241,9 @@ let private serializationTests = testCase "fails when not required field" <| fun _ -> - let json = JObject(JProperty("value", "bar"), JProperty("alpha", "lorem"), JProperty("beta", "ipsum")) + let json = + JObject(JProperty("value", "bar"), JProperty("alpha", "lorem"), JProperty("beta", "ipsum")) + |> LSPAny Expect.throws (fun _ -> @@ -278,11 +280,16 @@ let private serializationTests = JProperty("gamma", "dolor") ) - Expect.equal (json.ToString()) (expected.ToString()) "Items in AdditionalData should be normal properties" + Expect.equal + (json.JToken.ToString()) + (expected.ToString()) + "Items in AdditionalData should be normal properties" testCase "AdditionalData is not null when no additional properties" <| fun _ -> - let json = JObject(JProperty("name", "foo")) + let json = + JObject(JProperty("name", "foo")) + |> LSPAny let output = json @@ -295,7 +302,7 @@ let private serializationTests = testCase "changes lower cases start in F# to lower case in JSON" <| fun _ -> let o = {| Name = "foo"; SomeValue = 42 |} - let json = serialize o :?> JObject + let json = (serialize o).JToken :?> JObject let name = json.Property("name") Expect.equal name.Name "name" "name should be lower case start" @@ -323,7 +330,7 @@ let private serializationTests = |> Seq.mapi (fun i k -> (k, i)) |> Map.ofSeq - let json = serialize m :?> JObject + let json = (serialize m).JToken :?> JObject let propNames = json.Properties() @@ -458,7 +465,7 @@ let private serializationTests = <| fun _ -> let input = { AllOptional.OptionalName = None; OptionalValue = None } let json = serialize input - Expect.isEmpty (json.Children()) "There should be no properties" + Expect.isEmpty (json.JToken.Children()) "There should be no properties" testCase "doesn't fail when all fields given" <| fun _ -> @@ -517,6 +524,7 @@ let private serializationTests = JProperty(l (nameof o.Always), "sit"), JProperty(l (nameof o.AllowNull), "amet") ) + |> LSPAny json |> deserialize @@ -531,6 +539,7 @@ let private serializationTests = JProperty(l (nameof o.Always), "sit"), JProperty(l (nameof o.AllowNull), "amet") ) + |> LSPAny Expect.throws (fun _ -> @@ -549,6 +558,7 @@ let private serializationTests = JProperty(l (nameof o.Always), "sit"), JProperty(l (nameof o.AllowNull), "amet") ) + |> LSPAny json |> deserialize @@ -564,6 +574,7 @@ let private serializationTests = JProperty(l (nameof o.Always), "sit"), JProperty(l (nameof o.AllowNull), "amet") ) + |> LSPAny Expect.throws (fun _ -> @@ -582,6 +593,7 @@ let private serializationTests = JProperty(l (nameof o.DisallowNull), "dolor"), JProperty(l (nameof o.AllowNull), "amet") ) + |> LSPAny Expect.throws (fun _ -> @@ -601,6 +613,7 @@ let private serializationTests = JProperty(l (nameof o.Always), "sit"), JProperty(l (nameof o.AllowNull), null) ) + |> LSPAny json |> deserialize @@ -656,7 +669,7 @@ let private serializationTests = <| fun _ -> let input: U2 = U2.C2 { OneOptional.RequiredName = "foo"; OptionalValue = None } - let json = serialize input :?> JObject + let json = (serialize input).JToken :?> JObject Expect.hasLength (json.Properties()) 1 "There should be just one property" let prop = json.Property("requiredName") Expect.equal (prop.Value.ToString()) "foo" "Required Property should have correct value" @@ -673,7 +686,9 @@ let private serializationTests = testThereAndBackAgain input testCase "fails with missing required value" <| fun _ -> - let json = JToken.Parse """{"optionalValue": 42}""" + let json = + JToken.Parse """{"optionalValue": 42}""" + |> LSPAny Expect.throws (fun _ -> @@ -776,17 +791,18 @@ let private serializationTests = <| fun _ -> let textDoc = { OptionalVersionedTextDocumentIdentifier.Uri = "..."; Version = None } - let json = + let jsonAny = textDoc |> serialize - :?> JObject + + let json = jsonAny.JToken :?> JObject let prop = json.Property("version") let value = prop.Value Expect.equal (value.Type) (JTokenType.Null) "Version should be null" let prop = - json + jsonAny |> tryGetProperty (nameof textDoc.Version) |> Flip.Expect.wantSome "Property Version should exist" From 36c1111a26b0ab7c723abf27314f70e6aae489c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Saulius=20Menkevi=C4=8Dius?= Date: Fri, 5 Jun 2026 12:05:52 +0300 Subject: [PATCH 3/4] Add LSPAny.fromJToken --- src/Types.fs | 7 ++++++- tests/Benchmarks.fs | 6 +++++- tests/Tests.fs | 12 ++++++------ 3 files changed, 17 insertions(+), 8 deletions(-) diff --git a/src/Types.fs b/src/Types.fs index ee9b7c9..1b921d1 100644 --- a/src/Types.fs +++ b/src/Types.fs @@ -107,7 +107,8 @@ type U4<'T1, 'T2, 'T3, 'T4> = /// /// The internal representation is intentionally kept behind a single JToken property so /// that the backing type can be swapped to System.Text.Json.JsonElement in the future -/// with minimal impact on call sites. +/// with minimal impact on call sites. Prefer the fromJToken factory over the constructor +/// directly; a companion fromJsonElement can be added once the migration happens. [)>] type LSPAny(token: JToken) = @@ -129,6 +130,10 @@ type LSPAny(token: JToken) = interface System.IEquatable with member x.Equals(other) = JToken.DeepEquals(token, other.JToken) + /// Wraps a in an . + /// A companion fromJsonElement can be added here once the backing type is migrated to . + static member inline fromJToken(token: JToken) = LSPAny(token) + /// Newtonsoft.Json converter for . /// Reads any JSON value into a and wraps it; writes by delegating to the token. and LSPAnyConverter() = diff --git a/tests/Benchmarks.fs b/tests/Benchmarks.fs index e578e5d..1c2ffe8 100644 --- a/tests/Benchmarks.fs +++ b/tests/Benchmarks.fs @@ -679,7 +679,11 @@ type MultipleTypesBenchmarks() = Tooltip = Some(U2.C2 { Kind = MarkupKind.PlainText; Value = "some tooltip" }) PaddingLeft = Some true PaddingRight = Some false - Data = Some(LSPAny(JToken.FromObject "some data")) + Data = + Some( + JToken.FromObject "some data" + |> LSPAny.fromJToken + ) } let allLsp: obj[] = [| diff --git a/tests/Tests.fs b/tests/Tests.fs index e64617c..652d731 100644 --- a/tests/Tests.fs +++ b/tests/Tests.fs @@ -243,7 +243,7 @@ let private serializationTests = <| fun _ -> let json = JObject(JProperty("value", "bar"), JProperty("alpha", "lorem"), JProperty("beta", "ipsum")) - |> LSPAny + |> LSPAny.fromJToken Expect.throws (fun _ -> @@ -289,7 +289,7 @@ let private serializationTests = <| fun _ -> let json = JObject(JProperty("name", "foo")) - |> LSPAny + |> LSPAny.fromJToken let output = json @@ -688,7 +688,7 @@ let private serializationTests = <| fun _ -> let json = JToken.Parse """{"optionalValue": 42}""" - |> LSPAny + |> LSPAny.fromJToken Expect.throws (fun _ -> @@ -935,7 +935,7 @@ let private serializationTests = Tooltip = Some(U2.C1 "tooltipping") PaddingLeft = Some true PaddingRight = Some false - Data = Some(LSPAny(JToken.FromObject "some data")) + Data = Some(LSPAny.fromJToken (JToken.FromObject "some data")) } testThereAndBackAgain theInlayHint @@ -954,7 +954,7 @@ let private serializationTests = Tooltip = None PaddingLeft = None PaddingRight = None - Data = Some(LSPAny(JToken.FromObject data)) + Data = Some(LSPAny.fromJToken (JToken.FromObject data)) } let output = thereAndBackAgain theInlayHint @@ -1010,7 +1010,7 @@ let private serializationTests = Tooltip = Some(U2.C2 { Kind = MarkupKind.PlainText; Value = "some tooltip" }) PaddingLeft = Some true PaddingRight = Some false - Data = Some(LSPAny(JToken.FromObject "some data")) + Data = Some(LSPAny.fromJToken (JToken.FromObject "some data")) } testThereAndBackAgain theInlayHint From 599c493161d00472c635ea3ba52b57cb60e30914 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Saulius=20Menkevi=C4=8Dius?= Date: Fri, 5 Jun 2026 12:09:06 +0300 Subject: [PATCH 4/4] Add ref to STJ and add LSPAny.fromJsonElement + LSPAny.JsonElement --- src/Ionide.LanguageServerProtocol.fsproj | 1 + src/Types.fs | 15 ++++++++++++--- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/Ionide.LanguageServerProtocol.fsproj b/src/Ionide.LanguageServerProtocol.fsproj index 9b7c28d..4486235 100644 --- a/src/Ionide.LanguageServerProtocol.fsproj +++ b/src/Ionide.LanguageServerProtocol.fsproj @@ -36,6 +36,7 @@ + diff --git a/src/Types.fs b/src/Types.fs index 1b921d1..8ce4504 100644 --- a/src/Types.fs +++ b/src/Types.fs @@ -107,14 +107,19 @@ type U4<'T1, 'T2, 'T3, 'T4> = /// /// The internal representation is intentionally kept behind a single JToken property so /// that the backing type can be swapped to System.Text.Json.JsonElement in the future -/// with minimal impact on call sites. Prefer the fromJToken factory over the constructor -/// directly; a companion fromJsonElement can be added once the migration happens. +/// with minimal impact on call sites. Prefer the fromJToken / fromJsonElement +/// factories over the constructor directly. [)>] type LSPAny(token: JToken) = /// The underlying JSON token. member _.JToken: JToken = token + /// The value as a , bridged via raw JSON text. + /// Once the backing type is migrated to this will be a direct accessor. + member _.JsonElement: System.Text.Json.JsonElement = + System.Text.Json.JsonSerializer.Deserialize(token.ToString(Formatting.None)) + override _.ToString() = token.ToString(Formatting.None) override _.GetHashCode() = @@ -131,9 +136,13 @@ type LSPAny(token: JToken) = member x.Equals(other) = JToken.DeepEquals(token, other.JToken) /// Wraps a in an . - /// A companion fromJsonElement can be added here once the backing type is migrated to . static member inline fromJToken(token: JToken) = LSPAny(token) + /// Wraps a in an , bridged via raw JSON text. + /// Once the backing type is migrated to this will be a direct wrap. + static member inline fromJsonElement(element: System.Text.Json.JsonElement) = + LSPAny(JToken.Parse(element.GetRawText())) + /// Newtonsoft.Json converter for . /// Reads any JSON value into a and wraps it; writes by delegating to the token. and LSPAnyConverter() =