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/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/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/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..8ce4504 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,66 @@ 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. 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() = + // 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) + + /// Wraps a in an . + 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() = + 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..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(JToken.FromObject "some data") + Data = + Some( + JToken.FromObject "some data" + |> LSPAny.fromJToken + ) } let allLsp: obj[] = [| @@ -707,7 +711,7 @@ type MultipleTypesBenchmarks() = inlayHint |> serialize - let res = json.ToObject(o.GetType(), jsonRpcFormatter.JsonSerializer) + let res = json.JToken.ToObject(o.GetType(), jsonRpcFormatter.JsonSerializer) () [] @@ -722,7 +726,7 @@ type MultipleTypesBenchmarks() = example |> serialize - let res = json.ToObject(example.GetType(), jsonRpcFormatter.JsonSerializer) + let res = json.JToken.ToObject(example.GetType(), jsonRpcFormatter.JsonSerializer) () [] @@ -741,7 +745,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 +766,7 @@ type MultipleTypesBenchmarks() = data |> serialize - let _ = json.ToObject(typeof, jsonRpcFormatter.JsonSerializer) + let _ = json.JToken.ToObject(typeof, jsonRpcFormatter.JsonSerializer) () member _.ErasedUnion_ArgumentsSource() = @@ -791,7 +795,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 6fa1953..652d731 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.fromJToken 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.fromJToken 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.fromJToken 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" @@ -919,14 +935,12 @@ let private serializationTests = Tooltip = Some(U2.C1 "tooltipping") PaddingLeft = Some true PaddingRight = Some false - Data = Some(JToken.FromObject "some data") + Data = Some(LSPAny.fromJToken (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 +954,14 @@ let private serializationTests = Tooltip = None PaddingLeft = None PaddingRight = None - Data = Some(JToken.FromObject data) + Data = Some(LSPAny.fromJToken (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 +1010,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.fromJToken (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