From 5e86ada9bfb982630b61e5eef2325e9bd4b9b059 Mon Sep 17 00:00:00 2001 From: alsi-lawr <177320313+alsi-lawr@users.noreply.github.com> Date: Wed, 22 Jul 2026 15:22:00 +0100 Subject: [PATCH 1/3] feat: add Native AOT net10 protocol target --- src/AotJsonRpc.fs | 91 ++ src/AotLanguageServerProtocol.fs | 138 +++ src/AotRuntime.fs | 88 ++ src/AotTypes.fs | 62 ++ src/ClientServer.cg.fs | 7 +- src/Ionide.LanguageServerProtocol.fsproj | 64 +- .../AotContract/AotContract.fsproj | 18 + ...nguageServerProtocol.StaticMetadata.csproj | 21 + src/StaticMetadata/ProtocolJsonConverters.cs | 263 ++++++ .../MetaModelGenerator/GenerateAotMetadata.fs | 805 ++++++++++++++++++ .../GenerateClientServer.fs | 6 + tools/MetaModelGenerator/GenerateTypes.fs | 57 ++ .../MetaModelGenerator.fsproj | 5 +- tools/MetaModelGenerator/Program.fs | 52 ++ 14 files changed, 1662 insertions(+), 15 deletions(-) create mode 100644 src/AotJsonRpc.fs create mode 100644 src/AotLanguageServerProtocol.fs create mode 100644 src/AotRuntime.fs create mode 100644 src/AotTypes.fs create mode 100644 src/StaticMetadata/AotContract/AotContract.fsproj create mode 100644 src/StaticMetadata/Ionide.LanguageServerProtocol.StaticMetadata.csproj create mode 100644 src/StaticMetadata/ProtocolJsonConverters.cs create mode 100644 tools/MetaModelGenerator/GenerateAotMetadata.fs diff --git a/src/AotJsonRpc.fs b/src/AotJsonRpc.fs new file mode 100644 index 0000000..0e49b21 --- /dev/null +++ b/src/AotJsonRpc.fs @@ -0,0 +1,91 @@ +module Ionide.LanguageServerProtocol.JsonRpc + +open System +open System.Threading +open System.Threading.Tasks +open Ionide.LanguageServerProtocol.Types +open StreamJsonRpc + +module ErrorCodes = + let jsonrpcReservedErrorRangeStart = -32099 + let jsonrpcReservedErrorRangeEnd = -32000 + let lspReservedErrorRangeStart = -32899 + let lspReservedErrorRangeEnd = -32899 + +type Error = { + Code: int + Message: string + Data: LSPAny option +} with + + override _.ToString() = "Language server protocol error" + + static member Create(code: int, message: string) = { Code = code; Message = message; Data = None } + + static member ParseError(?message) = Error.Create(int Types.ErrorCodes.ParseError, defaultArg message "Parse error") + + static member InvalidRequest(?message) = + Error.Create(int Types.ErrorCodes.InvalidRequest, defaultArg message "Invalid Request") + + static member MethodNotFound(?message) = + Error.Create(int Types.ErrorCodes.MethodNotFound, defaultArg message "Method not found") + + static member InvalidParams(?message) = + Error.Create(int Types.ErrorCodes.InvalidParams, defaultArg message "Invalid params") + + static member InternalError(?message: string) = + Error.Create(int Types.ErrorCodes.InternalError, defaultArg message "Internal error") + + static member RequestCancelled(?message) = + Error.Create(int LSPErrorCodes.RequestCancelled, defaultArg message "Request cancelled") + +type LspResult<'result> = Result<'result, Error> +type AsyncLspResult<'result> = Async> + +module LspResult = + let success x : LspResult<_> = Ok x + let invalidParams message : LspResult<_> = Error(Error.InvalidParams message) + + let internalError<'a> (message: string) : LspResult<'a> = + Error(Error.Create(int Types.ErrorCodes.InvalidParams, message)) + + let notImplemented<'a> : LspResult<'a> = Error(Error.MethodNotFound()) + let requestCancelled<'a> : LspResult<'a> = Error(Error.RequestCancelled()) + +module AsyncLspResult = + let success x : AsyncLspResult<_> = async.Return(Ok x) + let invalidParams message : AsyncLspResult<_> = async.Return(LspResult.invalidParams message) + let internalError message : AsyncLspResult<_> = async.Return(LspResult.internalError message) + let notImplemented<'a> : AsyncLspResult<'a> = async.Return LspResult.notImplemented + let requestCancelled<'a> : AsyncLspResult<'a> = async.Return LspResult.requestCancelled + +module Requests = + let requestHandling<'param, 'result> (run: 'param -> AsyncLspResult<'result>) : Delegate = + let runAsTask param ct = + let pending = run param + + async { + let! result = pending + + match result with + | Ok value -> return value + | Error error -> + let rpcException = LocalRpcException(error.Message) + rpcException.ErrorCode <- error.Code + + rpcException.ErrorData <- + error.Data + |> Option.map box + |> Option.defaultValue null + + return raise rpcException + } + |> fun operation -> Async.StartAsTask(operation, cancellationToken = ct) + + Func<'param, CancellationToken, Task<'result>>(runAsTask) :> Delegate + + let internal notificationSuccess (response: Async) = + async { + do! response + return Ok() + } \ No newline at end of file diff --git a/src/AotLanguageServerProtocol.fs b/src/AotLanguageServerProtocol.fs new file mode 100644 index 0000000..4d1988e --- /dev/null +++ b/src/AotLanguageServerProtocol.fs @@ -0,0 +1,138 @@ +namespace Ionide.LanguageServerProtocol + +module Server = + open System + open System.IO + open System.Threading + open System.Threading.Tasks + open Ionide.LanguageServerProtocol.JsonRpc + open Ionide.LanguageServerProtocol.Logging + open Ionide.LanguageServerProtocol.StaticMetadata + open StreamJsonRpc + open StreamJsonRpc.Protocol + + type ClientNotificationSender = string -> obj -> AsyncLspResult + + type ClientRequestSender = + abstract member Send<'a> : string -> obj -> AsyncLspResult<'a> + + let logger = LogProvider.getLoggerByName "LSP Server" + + type LspCloseReason = + | RequestedByClient = 0 + | ErrorExitWithoutShutdown = 1 + | ErrorStreamClosed = 2 + + let requestHandling<'param, 'result> (run: 'param -> AsyncLspResult<'result>) = Requests.requestHandling run + + let serverRequestHandling<'server, 'param, 'result when 'server :> ILspServer> + (run: 'server -> 'param -> AsyncLspResult<'result>) + : Mappings.ServerRequestHandling<'server> = + { Run = fun server -> requestHandling (run server) } + + let defaultRequestHandlings () : Map> = + Mappings.routeMappings () + |> Map.ofList + + type private StaticProtocolRpc(handler: IJsonRpcMessageHandler) = + inherit JsonRpc(handler) + + override _.IsFatalException(exception': Exception) = + match exception' with + | :? LocalRpcException + | :? System.Text.Json.JsonException -> false + | _ -> true + + override this.CreateErrorDetails(request, exception') = + match exception' with + | :? System.Text.Json.JsonException as jsonException -> + JsonRpcError.ErrorDetail(Code = JsonRpcErrorCode.ParseError, Message = jsonException.Message) + | _ -> base.CreateErrorDetails(request, exception') + + let private run<'client, 'server when 'client :> ILspClient and 'server :> ILspServer> + (requestHandlings: Map>) + (handler: IJsonRpcMessageHandler) + (clientCreator: (ClientNotificationSender * ClientRequestSender) -> 'client) + (serverCreator: 'client -> 'server) + = + use jsonRpc = new StaticProtocolRpc(handler) + + let sendNotification methodName (value: obj) = + async { + do! + ProtocolMetadata.NotifyAsync(jsonRpc, methodName, ProtocolMetadata.Serialize(value)) + |> Async.AwaitTask + + return LspResult.success () + } + + let sendRequest methodName (value: obj) = + async { + let! response = + ProtocolMetadata.InvokeAsync(jsonRpc, methodName, ProtocolMetadata.Serialize(value)) + |> Async.AwaitTask + + return + ProtocolMetadata.Deserialize<'response>(response) + |> LspResult.success + } + + use client = + clientCreator ( + sendNotification, + { new ClientRequestSender with + member _.Send methodName value = sendRequest methodName value + } + ) + + use server = serverCreator client + let mutable shutdownReceived = false + let mutable exitReceived = false + use exitSemaphore = new SemaphoreSlim(0, 1) + + let target = + StaticProtocolTarget( + server, + requestHandlings, + Action(fun () -> shutdownReceived <- true), + Action(fun () -> + exitReceived <- true + + exitSemaphore.Release() + |> ignore + ) + ) + + jsonRpc.AddLocalRpcTarget(ProtocolMetadata.Target, target, null) + jsonRpc.StartListening() + let completed = Task.WaitAny(jsonRpc.Completion, exitSemaphore.WaitAsync()) + + if + completed = 0 + && not jsonRpc.Completion.IsCompletedSuccessfully + then + jsonRpc.Completion.GetAwaiter().GetResult() + + match shutdownReceived, exitReceived with + | true, true -> LspCloseReason.RequestedByClient + | false, true -> LspCloseReason.ErrorExitWithoutShutdown + | _ -> LspCloseReason.ErrorStreamClosed + + let start<'client, 'server when 'client :> ILspClient and 'server :> ILspServer> + (requestHandlings: Map>) + (input: Stream) + (output: Stream) + (clientCreator: (ClientNotificationSender * ClientRequestSender) -> 'client) + (serverCreator: 'client -> 'server) + = + use handler = new HeaderDelimitedMessageHandler(output, input, FormatterFactory.Create()) + run requestHandlings handler clientCreator serverCreator + + let startWs<'client, 'server when 'client :> ILspClient and 'server :> ILspServer> + (requestHandlings: Map>) + (socket: Net.WebSockets.WebSocket) + (clientCreator: (ClientNotificationSender * ClientRequestSender) -> 'client) + (serverCreator: 'client -> 'server) + = + use handler = new WebSocketMessageHandler(socket, FormatterFactory.Create()) + run requestHandlings handler clientCreator serverCreator \ No newline at end of file diff --git a/src/AotRuntime.fs b/src/AotRuntime.fs new file mode 100644 index 0000000..fe7595c --- /dev/null +++ b/src/AotRuntime.fs @@ -0,0 +1,88 @@ +namespace Ionide.LanguageServerProtocol + +module internal AotRuntime = + open System + open System.Text.Json + open System.Threading + open System.Threading.Tasks + open Ionide.LanguageServerProtocol.JsonRpc + open Ionide.LanguageServerProtocol.StaticMetadata + open StreamJsonRpc + + let private findHandling server (handlings: Map>) route = + match Map.tryFind route handlings with + | Some handling -> handling.Run server + | None -> + let rpcException = LocalRpcException(String.Concat("Method not found: ", route)) + rpcException.ErrorCode <- int Types.ErrorCodes.MethodNotFound + raise rpcException + + let invokeWithParameter<'server, 'parameter, 'result when 'server :> ILspServer> + (server: 'server) + (handlings: Map>) + route + (request: JsonElement) + (cancellationToken: CancellationToken) + (_infer: 'parameter -> AsyncLspResult<'result>) + = + task { + let handling = findHandling server handlings route :?> Func<'parameter, CancellationToken, Task<'result>> + let parameter = ProtocolMetadata.Deserialize<'parameter>(request) + let! result = handling.Invoke(parameter, cancellationToken) + return ProtocolMetadata.Serialize(result) + } + + let invokeWithoutParameter<'server, 'result when 'server :> ILspServer> + (server: 'server) + (handlings: Map>) + route + (cancellationToken: CancellationToken) + (_infer: unit -> AsyncLspResult<'result>) + = + task { + let handling = findHandling server handlings route :?> Func> + let! result = handling.Invoke((), cancellationToken) + return ProtocolMetadata.Serialize(result) + } + + let notifyWithParameter<'server, 'parameter when 'server :> ILspServer> + (server: 'server) + (handlings: Map>) + route + (request: JsonElement) + (cancellationToken: CancellationToken) + (_infer: 'parameter -> Async) + : Task = + invokeWithParameter + server + handlings + route + request + cancellationToken + (fun parameter -> + async { + do! _infer parameter + return Ok() + } + ) + :> Task + + let notifyWithoutParameter<'server when 'server :> ILspServer> + (server: 'server) + (handlings: Map>) + route + (cancellationToken: CancellationToken) + (_infer: unit -> Async) + : Task = + invokeWithoutParameter + server + handlings + route + cancellationToken + (fun () -> + async { + do! _infer () + return Ok() + } + ) + :> Task \ No newline at end of file diff --git a/src/AotTypes.fs b/src/AotTypes.fs new file mode 100644 index 0000000..5abef2c --- /dev/null +++ b/src/AotTypes.fs @@ -0,0 +1,62 @@ +namespace Ionide.LanguageServerProtocol.Types + +open System +open System.Text.Json + +/// Marks the fixed discriminator value of a protocol record used in an erased union. +type UnionKindAttribute(value: string) = + inherit Attribute() + member _.Value = value + +/// Marks a protocol union whose case wrapper is erased on the JSON wire. +type ErasedUnionAttribute() = + inherit Attribute() + +[] +type U2<'T1, 'T2> = + | C1 of 'T1 + | C2 of 'T2 + + override _.ToString() = "U2" + +[] +type U3<'T1, 'T2, 'T3> = + | C1 of 'T1 + | C2 of 'T2 + | C3 of 'T3 + + override _.ToString() = "U3" + +[] +type U4<'T1, 'T2, 'T3, 'T4> = + | C1 of 'T1 + | C2 of 'T2 + | C3 of 'T3 + | C4 of 'T4 + + override _.ToString() = "U4" + +/// A JSON value carried in an LSP `any` slot. +[] +type LSPAny private (element: JsonElement) = + let element = element.Clone() + + /// The underlying System.Text.Json value. + member _.JsonElement = element + + override _.ToString() = element.GetRawText() + + override _.GetHashCode() = StringComparer.Ordinal.GetHashCode(element.GetRawText()) + + override _.Equals(obj: obj) = + match obj with + | :? LSPAny as value -> StringComparer.Ordinal.Equals(element.GetRawText(), value.JsonElement.GetRawText()) + | _ -> false + + interface IEquatable with + member _.Equals(other) = + not (obj.ReferenceEquals(other, null)) + && StringComparer.Ordinal.Equals(element.GetRawText(), other.JsonElement.GetRawText()) + + /// Wraps a System.Text.Json value without retaining its owning JsonDocument. + static member fromJsonElement(element: JsonElement) = LSPAny(element) \ No newline at end of file diff --git a/src/ClientServer.cg.fs b/src/ClientServer.cg.fs index e7e0f61..d7357e5 100644 --- a/src/ClientServer.cg.fs +++ b/src/ClientServer.cg.fs @@ -394,7 +394,12 @@ type ILspClient = abstract WorkspaceApplyEdit: ApplyWorkspaceEditParams -> AsyncLspResult module Mappings = - type ServerRequestHandling<'server when 'server :> ILspServer> = { Run: 'server -> System.Delegate } + type ServerRequestHandling<'server when 'server :> ILspServer> = + { Run: 'server -> System.Delegate } +#if NET10_0 + + override _.ToString() = "ServerRequestHandling" +#endif let routeMappings () = let serverRequestHandling run = { diff --git a/src/Ionide.LanguageServerProtocol.fsproj b/src/Ionide.LanguageServerProtocol.fsproj index 4486235..086df4b 100644 --- a/src/Ionide.LanguageServerProtocol.fsproj +++ b/src/Ionide.LanguageServerProtocol.fsproj @@ -1,7 +1,7 @@  - netstandard2.0 + netstandard2.0;net10.0 true $(MSBuildThisFileDirectory)../CHANGELOG.md Library for implementing Language Server Protocol in F#. @@ -11,6 +11,7 @@ README.md https://github.com/ionide/LanguageServerProtocol 3.17.0 + true - - - + + + + + + - - - + + + + + + - - + + - - + + + + + $(MSBuildThisFileDirectory)StaticMetadata/bin/$(Configuration)/net10.0/Ionide.LanguageServerProtocol.StaticMetadata.dll + true + + + $(TargetsForTfmSpecificBuildOutput);IncludeStaticMetadataPackageAsset + + + + + <_GeneratorProject Include="../tools/MetaModelGenerator/MetaModelGenerator.fsproj" /> + <_AotContractProject Include="$(MSBuildThisFileDirectory)StaticMetadata/AotContract/AotContract.fsproj" /> + + + + + + + + + + + + + + Ionide.LanguageServerProtocol.StaticMetadata.dll + + + + <_MetaModelInputs Include="$(MSBuildThisFileDirectory)../data/$(LSPVersion)/metaModel.json" /> @@ -60,7 +98,7 @@ @@ -105,4 +143,4 @@ Command="@(_GenerateCommand2, ' ')" /> - \ No newline at end of file + diff --git a/src/StaticMetadata/AotContract/AotContract.fsproj b/src/StaticMetadata/AotContract/AotContract.fsproj new file mode 100644 index 0000000..bdf3a1e --- /dev/null +++ b/src/StaticMetadata/AotContract/AotContract.fsproj @@ -0,0 +1,18 @@ + + + net10.0 + Ionide.LanguageServerProtocol + Ionide.LanguageServerProtocol + false + false + false + + + + + + + + + + diff --git a/src/StaticMetadata/Ionide.LanguageServerProtocol.StaticMetadata.csproj b/src/StaticMetadata/Ionide.LanguageServerProtocol.StaticMetadata.csproj new file mode 100644 index 0000000..a4f5fa3 --- /dev/null +++ b/src/StaticMetadata/Ionide.LanguageServerProtocol.StaticMetadata.csproj @@ -0,0 +1,21 @@ + + + net10.0 + enable + enable + false + true + true + $(MSBuildThisFileDirectory)AotContract/bin/$(Configuration)/net10.0/Ionide.LanguageServerProtocol.dll + $(MSBuildThisFileDirectory)../obj/$(Configuration)/net10.0/AotMetadata.generated.cs + + + + $(ContractAssemblyPath) + false + + + + + + diff --git a/src/StaticMetadata/ProtocolJsonConverters.cs b/src/StaticMetadata/ProtocolJsonConverters.cs new file mode 100644 index 0000000..7d36ac1 --- /dev/null +++ b/src/StaticMetadata/ProtocolJsonConverters.cs @@ -0,0 +1,263 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; +using Ionide.LanguageServerProtocol.Types; +using Microsoft.FSharp.Collections; +using Microsoft.FSharp.Core; + +namespace Ionide.LanguageServerProtocol.StaticMetadata; + +internal class FSharpOptionJsonConverter : JsonConverter> +{ + public override bool HandleNull => true; + + public override FSharpOption Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Null) + { + return FSharpOption.None; + } + + JsonTypeInfo typeInfo = (JsonTypeInfo)options.GetTypeInfo(typeof(T)); + T value = JsonSerializer.Deserialize(ref reader, typeInfo)!; + return FSharpOption.Some(value); + } + + public override void Write(Utf8JsonWriter writer, FSharpOption value, JsonSerializerOptions options) + { + if (FSharpOption.get_IsNone(value)) + { + writer.WriteNullValue(); + return; + } + + JsonSerializer.Serialize(writer, value.Value, (JsonTypeInfo)options.GetTypeInfo(typeof(T))); + } +} + +internal class FSharpMapJsonConverter : JsonConverter> +{ + public override FSharpMap Read( + ref Utf8JsonReader reader, + Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType != JsonTokenType.StartObject) + { + throw new JsonException("A protocol map must be a JSON object."); + } + + var values = new List>(); + JsonTypeInfo typeInfo = (JsonTypeInfo)options.GetTypeInfo(typeof(T)); + while (reader.Read() && reader.TokenType != JsonTokenType.EndObject) + { + if (reader.TokenType != JsonTokenType.PropertyName) + { + throw new JsonException("Expected a protocol map key."); + } + + string key = reader.GetString()!; + if (!reader.Read()) + throw new JsonException("Expected a protocol map value."); + values.Add(Tuple.Create(key, JsonSerializer.Deserialize(ref reader, typeInfo)!)); + } + + return new FSharpMap(values); + } + + public override void Write(Utf8JsonWriter writer, FSharpMap value, JsonSerializerOptions options) + { + JsonTypeInfo typeInfo = (JsonTypeInfo)options.GetTypeInfo(typeof(T)); + writer.WriteStartObject(); + foreach (KeyValuePair item in value) + { + writer.WritePropertyName(item.Key); + JsonSerializer.Serialize(writer, item.Value, typeInfo); + } + writer.WriteEndObject(); + } +} + +internal abstract class ErasedUnionJsonConverter : JsonConverter +{ + protected abstract Type[] CandidateTypes { get; } + protected abstract string?[] UnionKinds { get; } + protected abstract TUnion Create(int index, object? value); + protected abstract object? GetValue(TUnion value); + + public override TUnion Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + using JsonDocument document = JsonDocument.ParseValue(ref reader); + JsonElement element = document.RootElement; + int selected = SelectCandidate(element, options); + ProtocolMetadata.ValidateRequired(CandidateTypes[selected], element); + JsonTypeInfo typeInfo = options.GetTypeInfo(CandidateTypes[selected]); + object? value = JsonSerializer.Deserialize(element, typeInfo); + return Create(selected, value); + } + + public override void Write(Utf8JsonWriter writer, TUnion value, JsonSerializerOptions options) + { + object? nested = GetValue(value); + if (nested is null) + { + writer.WriteNullValue(); + return; + } + + JsonSerializer.Serialize(writer, nested, options.GetTypeInfo(nested.GetType())); + } + + private int SelectCandidate(JsonElement element, JsonSerializerOptions options) + { + for (int index = 0; index < CandidateTypes.Length; index++) + { + Type candidate = CandidateTypes[index]; + if ( + (element.ValueKind == JsonValueKind.String && candidate == typeof(string)) + || (element.ValueKind == JsonValueKind.Number && IsNumber(candidate)) + || ( + (element.ValueKind == JsonValueKind.True || element.ValueKind == JsonValueKind.False) + && candidate == typeof(bool) + ) + || (element.ValueKind == JsonValueKind.Array && candidate.IsArray) + ) + { + return index; + } + } + + if (element.ValueKind == JsonValueKind.Object && element.TryGetProperty("kind", out JsonElement kindElement)) + { + string? kind = kindElement.GetString(); + for (int index = 0; index < UnionKinds.Length; index++) + { + if (UnionKinds[index] == kind) + return index; + } + } + + if (element.ValueKind == JsonValueKind.Object) + { + foreach (int index in Enumerable.Range(0, CandidateTypes.Length)) + { + JsonTypeInfo candidate = options.GetTypeInfo(CandidateTypes[index]); + if (candidate.Kind != JsonTypeInfoKind.Object) + continue; + bool matches = true; + foreach (JsonProperty property in element.EnumerateObject()) + { + if ( + !candidate.Properties.Any(candidateProperty => + StringComparer.OrdinalIgnoreCase.Equals(candidateProperty.Name, property.Name) + ) + ) + { + matches = false; + break; + } + } + + if (matches) + return index; + } + } + + throw new JsonException($"No case of {typeof(TUnion)} accepts this JSON value."); + } + + private static bool IsNumber(Type type) => + type == typeof(byte) + || type == typeof(sbyte) + || type == typeof(short) + || type == typeof(ushort) + || type == typeof(int) + || type == typeof(uint) + || type == typeof(long) + || type == typeof(ulong) + || type == typeof(float) + || type == typeof(double) + || type == typeof(decimal); +} + +internal class ErasedUnion2JsonConverter : ErasedUnionJsonConverter> +{ + protected override Type[] CandidateTypes => [typeof(T1), typeof(T2)]; + protected override string?[] UnionKinds => [null, null]; + + protected override U2 Create(int index, object? value) => + index switch + { + 0 => U2.NewC1((T1)value!), + 1 => U2.NewC2((T2)value!), + _ => throw new JsonException(), + }; + + protected override object? GetValue(U2 value) => + value switch + { + U2.C1 first => first.Item, + U2.C2 second => second.Item, + _ => throw new JsonException(), + }; +} + +internal class ErasedUnion3JsonConverter : ErasedUnionJsonConverter> +{ + protected override Type[] CandidateTypes => [typeof(T1), typeof(T2), typeof(T3)]; + protected override string?[] UnionKinds => [null, null, null]; + + protected override U3 Create(int index, object? value) => + index switch + { + 0 => U3.NewC1((T1)value!), + 1 => U3.NewC2((T2)value!), + 2 => U3.NewC3((T3)value!), + _ => throw new JsonException(), + }; + + protected override object? GetValue(U3 value) => + value switch + { + U3.C1 item => item.Item, + U3.C2 item => item.Item, + U3.C3 item => item.Item, + _ => throw new JsonException(), + }; +} + +internal class ErasedUnion4JsonConverter : ErasedUnionJsonConverter> +{ + protected override Type[] CandidateTypes => [typeof(T1), typeof(T2), typeof(T3), typeof(T4)]; + protected override string?[] UnionKinds => [null, null, null, null]; + + protected override U4 Create(int index, object? value) => + index switch + { + 0 => U4.NewC1((T1)value!), + 1 => U4.NewC2((T2)value!), + 2 => U4.NewC3((T3)value!), + 3 => U4.NewC4((T4)value!), + _ => throw new JsonException(), + }; + + protected override object? GetValue(U4 value) => + value switch + { + U4.C1 item => item.Item, + U4.C2 item => item.Item, + U4.C3 item => item.Item, + U4.C4 item => item.Item, + _ => throw new JsonException(), + }; +} + +internal sealed class LspAnyJsonConverter : JsonConverter +{ + public override LSPAny Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => + LSPAny.fromJsonElement(JsonElement.ParseValue(ref reader)); + + public override void Write(Utf8JsonWriter writer, LSPAny value, JsonSerializerOptions options) => + value.JsonElement.WriteTo(writer); +} diff --git a/tools/MetaModelGenerator/GenerateAotMetadata.fs b/tools/MetaModelGenerator/GenerateAotMetadata.fs new file mode 100644 index 0000000..273f1fb --- /dev/null +++ b/tools/MetaModelGenerator/GenerateAotMetadata.fs @@ -0,0 +1,805 @@ +namespace MetaModelGenerator + +module GenerateAotMetadata = + open System + open System.IO + open System.Reflection + open System.Text + open Microsoft.FSharp.Reflection + + let private normalizeMethod (value: string) = + value.Split( + '/', + StringSplitOptions.RemoveEmptyEntries + ||| StringSplitOptions.TrimEntries + ) + |> Array.filter ((<>) "$") + |> Array.map (fun part -> + Char.ToUpperInvariant(part.[0]).ToString() + + part.Substring(1) + ) + |> String.concat "" + + let private csharpTypeName (ty: Type) = + let rec render (value: Type) = + if value.IsArray then + render (value.GetElementType()) + + "[]" + elif value.IsGenericType then + let definitionName = value.GetGenericTypeDefinition().FullName + let tick = definitionName.IndexOf('`') + let name = definitionName.Substring(0, tick).Replace('+', '.') + + let arguments = + value.GetGenericArguments() + |> Array.map render + |> String.concat ", " + + $"global::{name}<{arguments}>" + else + $"global::{value.FullName.Replace('+', '.')}" + + render ty + + let private isClosedProtocolType (ty: Type) = + not ty.ContainsGenericParameters + && not ty.IsPointer + && not ty.IsByRef + && ty + <> typeof + && ty + <> typeof + + let private collectTypes (assembly: Assembly) = + let optionDefinition = typedefof> + let mapDefinition = typedefof> + let protocolNamespace = "Ionide.LanguageServerProtocol.Types" + let mutable seen = Set.empty + let mutable closedSpecials = Map.empty + + let rec visit (ty: Type) = + if + isNull ty + || not (isClosedProtocolType ty) + then + () + else + let key = ty.AssemblyQualifiedName + + if + not (isNull key) + && not (Set.contains key seen) + then + seen <- Set.add key seen + + if ty.IsArray then + visit (ty.GetElementType()) + elif ty.IsGenericType then + let definition = ty.GetGenericTypeDefinition() + + if + definition = optionDefinition + || definition = mapDefinition + || (not (isNull definition.FullName) + && definition.FullName.StartsWith("Ionide.LanguageServerProtocol.Types.U", StringComparison.Ordinal)) + then + closedSpecials <- Map.add key ty closedSpecials + + ty.GetGenericArguments() + |> Array.iter visit + + if + ty.Namespace = protocolNamespace + && not ty.IsGenericTypeDefinition + then + ty.GetProperties( + BindingFlags.Public + ||| BindingFlags.Instance + ) + |> Array.iter (fun property -> visit property.PropertyType) + + let roots = + assembly.GetExportedTypes() + |> Array.filter (fun ty -> + ty.Namespace = protocolNamespace + && not ty.IsGenericTypeDefinition + && not ty.IsInterface + && not ( + ty.IsAbstract + && ty.IsSealed + ) + && not (typeof.IsAssignableFrom ty) + && not (ty.Name.EndsWith("Converter", StringComparison.Ordinal)) + ) + + roots + |> Array.iter visit + + for contractName in + [ + "Ionide.LanguageServerProtocol.ILspServer" + "Ionide.LanguageServerProtocol.ILspClient" + ] do + let contract = assembly.GetType(contractName, true) + + for methodInfo in contract.GetMethods() do + methodInfo.GetParameters() + |> Array.iter (fun parameter -> visit parameter.ParameterType) + + visit methodInfo.ReturnType + + roots + |> Array.sortBy _.FullName, + closedSpecials + |> Map.toArray + |> Array.map snd + + let private enumMemberValue (field: FieldInfo) = + field.CustomAttributes + |> Seq.tryFind (fun attribute -> + attribute.AttributeType.FullName = "System.Runtime.Serialization.EnumMemberAttribute" + ) + |> Option.bind (fun attribute -> + attribute.NamedArguments + |> Seq.tryFind (fun argument -> argument.MemberName = "Value") + |> Option.map (fun argument -> string argument.TypedValue.Value) + ) + |> Option.defaultValue field.Name + + let private unionKindValue (ty: Type) = + ty.GetProperties( + BindingFlags.Public + ||| BindingFlags.Instance + ) + |> Array.tryPick (fun property -> + property.CustomAttributes + |> Seq.tryFind (fun attribute -> + attribute.AttributeType.FullName = "Ionide.LanguageServerProtocol.Types.UnionKindAttribute" + ) + |> Option.bind (fun attribute -> + attribute.ConstructorArguments + |> Seq.tryHead + |> Option.map (fun argument -> string argument.Value) + ) + ) + + let private escape (value: string) = value.Replace("\\", "\\\\").Replace("\"", "\\\"") + + let private generateCSharp (assembly: Assembly) (metaModel: MetaModel.MetaModel) = + let roots, specials = collectTypes assembly + + let validationTypes = + let mutable seen = Set.empty + let collected = ResizeArray() + + let rec visit (ty: Type) = + if + not (isNull ty) + && isClosedProtocolType ty + then + let key = ty.AssemblyQualifiedName + + if + not (isNull key) + && not (Set.contains key seen) + then + seen <- Set.add key seen + collected.Add ty + + if ty.IsArray then + visit (ty.GetElementType()) + elif ty.IsGenericType then + ty.GetGenericArguments() + |> Array.iter visit + + if + FSharpType.IsRecord( + ty, + BindingFlags.Public + ||| BindingFlags.NonPublic + ) + then + FSharpType.GetRecordFields( + ty, + BindingFlags.Public + ||| BindingFlags.NonPublic + ) + |> Array.iter (fun property -> visit property.PropertyType) + + roots + |> Array.iter visit + + specials + |> Array.iter visit + + collected + |> Seq.sortBy csharpTypeName + |> Seq.toArray + + let enums = + roots + |> Array.filter (fun ty -> + ty.IsEnum + && ty.GetFields( + BindingFlags.Public + ||| BindingFlags.Static + ) + |> Array.exists (fun field -> + field.CustomAttributes + |> Seq.exists (fun attribute -> + attribute.AttributeType.FullName = "System.Runtime.Serialization.EnumMemberAttribute" + ) + ) + ) + + let optionDefinition = typedefof> + let mapDefinition = typedefof> + + let options = + specials + |> Array.filter (fun ty -> + ty.IsGenericType + && ty.GetGenericTypeDefinition() = optionDefinition + ) + + let maps = + specials + |> Array.filter (fun ty -> + ty.IsGenericType + && ty.GetGenericTypeDefinition() = mapDefinition + ) + + let unions = + specials + |> Array.filter (fun ty -> + ty.IsGenericType + && ty + .GetGenericTypeDefinition() + .FullName.StartsWith("Ionide.LanguageServerProtocol.Types.U", StringComparison.Ordinal) + ) + + let records = + validationTypes + |> Array.filter (fun ty -> + FSharpType.IsRecord( + ty, + BindingFlags.Public + ||| BindingFlags.NonPublic + ) + ) + + let validationOptions = + validationTypes + |> Array.filter (fun ty -> + ty.IsGenericType + && ty.GetGenericTypeDefinition() = optionDefinition + ) + + let validationMaps = + validationTypes + |> Array.filter (fun ty -> + ty.IsGenericType + && ty.GetGenericTypeDefinition() = mapDefinition + && ty.GetGenericArguments().[0] = typeof + ) + + let validationArrays = + validationTypes + |> Array.filter _.IsArray + + let serverRequests = + metaModel.Requests + |> Array.filter Proposed.checkProposed + |> Array.filter (fun request -> + request.MessageDirection = MetaModel.MessageDirection.ClientToServer + || request.MessageDirection = MetaModel.MessageDirection.Both + ) + + let serverNotifications = + metaModel.Notifications + |> Array.filter Proposed.checkProposed + |> Array.filter (fun notification -> + notification.MessageDirection = MetaModel.MessageDirection.ClientToServer + || notification.MessageDirection = MetaModel.MessageDirection.Both + ) + + let builder = StringBuilder() + + let line (text: string) = + builder.AppendLine(text) + |> ignore + + line "// " + line "#nullable enable" + line "using System.Diagnostics.CodeAnalysis;" + line "using System.Runtime.CompilerServices;" + line "using System.Text.Json;" + line "using System.Text.Json.Serialization;" + line "using System.Text.Json.Serialization.Metadata;" + line "using Microsoft.FSharp.Collections;" + line "using Microsoft.FSharp.Core;" + line "using PolyType;" + line "using StreamJsonRpc;" + line "[assembly: InternalsVisibleTo(\"Ionide.LanguageServerProtocol\")]" + line "namespace Ionide.LanguageServerProtocol.StaticMetadata;" + line "" + line "[GenerateShape(IncludeMethods = MethodShapeFlags.PublicInstance)]" + line "internal abstract partial class ProtocolTargetContract" + line "{" + + for request in serverRequests do + let name = normalizeMethod request.Method + + let parameter = + if Array.isEmpty request.ParamsSafe then + "" + else + "JsonElement request, " + + let singleParameter = + if Array.isEmpty request.ParamsSafe then + "" + else + ", UseSingleObjectParameterDeserialization = true" + + line $" [JsonRpcMethod(\"{escape request.Method}\"{singleParameter})]" + line $" public abstract Task {name}Async({parameter}CancellationToken cancellationToken);" + + for notification in serverNotifications do + let name = normalizeMethod notification.Method + + let parameter = + if Array.isEmpty notification.ParamsSafe then + "" + else + "JsonElement request, " + + let singleParameter = + if Array.isEmpty notification.ParamsSafe then + "" + else + ", UseSingleObjectParameterDeserialization = true" + + line $" [JsonRpcMethod(\"{escape notification.Method}\"{singleParameter})]" + line $" public abstract Task {name}Async({parameter}CancellationToken cancellationToken);" + + line "}" + line "" + + let converterNames = ResizeArray() + + options + |> Array.iteri (fun index ty -> + let name = $"OptionConverter{index}" + converterNames.Add name + + line + $"internal sealed class {name} : FSharpOptionJsonConverter<{csharpTypeName (ty.GetGenericArguments().[0])}> {{ }}" + ) + + maps + |> Array.iteri (fun index ty -> + let args = ty.GetGenericArguments() + + if args.[0] = typeof then + let name = $"MapConverter{index}" + converterNames.Add name + line $"internal sealed class {name} : FSharpMapJsonConverter<{csharpTypeName args.[1]}> {{ }}" + ) + + unions + |> Array.iteri (fun index ty -> + let args = ty.GetGenericArguments() + let name = $"UnionConverter{index}" + converterNames.Add name + + let kinds = + args + |> Array.map unionKindValue + |> Array.map ( + Option.map (fun value -> $"\"{escape value}\"") + >> Option.defaultValue "null" + ) + |> String.concat ", " + + let typeArguments = + args + |> Array.map csharpTypeName + |> String.concat ", " + + let baseName = $"ErasedUnion{args.Length}JsonConverter<{typeArguments}>" + + line + $"internal sealed class {name} : {baseName} {{ protected override string?[] UnionKinds => new string?[] {{ {kinds} }}; }}" + ) + + enums + |> Array.iteri (fun index ty -> + let name = $"EnumConverter{index}" + converterNames.Add name + line $"internal sealed class {name} : JsonConverter<{csharpTypeName ty}>" + line "{" + + line + $" public override {csharpTypeName ty} Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => reader.GetString() switch" + + line " {" + + for field in + ty.GetFields( + BindingFlags.Public + ||| BindingFlags.Static + ) do + line $" \"{escape (enumMemberValue field)}\" => {csharpTypeName ty}.{field.Name}," + + line " _ => throw new JsonException(\"Unknown protocol enumeration value.\")," + line " };" + + line + $" public override void Write(Utf8JsonWriter writer, {csharpTypeName ty} value, JsonSerializerOptions options)" + + line " {" + line " writer.WriteStringValue(value switch" + line " {" + + for field in + ty.GetFields( + BindingFlags.Public + ||| BindingFlags.Static + ) do + line $" {csharpTypeName ty}.{field.Name} => \"{escape (enumMemberValue field)}\"," + + line " _ => throw new JsonException(\"Unknown protocol enumeration value.\")," + line " });" + line " }" + line "}" + ) + + converterNames.Add "LspAnyJsonConverter" + + let converters = + converterNames + |> Seq.map (fun name -> $"typeof({name})") + |> String.concat ", " + + line "" + + line + $"[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, PropertyNameCaseInsensitive = true, RespectRequiredConstructorParameters = false, Converters = new[] {{ {converters} }})]" + + for index, ty in Array.indexed roots do + line $"[JsonSerializable(typeof({csharpTypeName ty}), TypeInfoPropertyName = \"ProtocolType{index}\")]" + + for index, ty in Array.indexed specials do + line $"[JsonSerializable(typeof({csharpTypeName ty}), TypeInfoPropertyName = \"ProtocolSpecialType{index}\")]" + + line "[JsonSerializable(typeof(JsonElement))]" + line "internal partial class ProtocolJsonSerializerContext : JsonSerializerContext;" + line "" + line "internal static class ProtocolMetadata" + line "{" + + line + " internal static IJsonTypeInfoResolver Resolver { get; } = ProtocolJsonSerializerContext.Default.WithAddedModifier(static typeInfo =>" + + line " {" + line " if (typeInfo.Kind != JsonTypeInfoKind.Object) return;" + line " foreach (JsonPropertyInfo property in typeInfo.Properties) property.IsRequired = false;" + line " });" + + line " private static JsonSerializerOptions SerializerOptions { get; } = CreateSerializerOptions();" + + line + " internal static RpcTargetMetadata Target { get; } = RpcTargetMetadata.FromShape();" + + line "" + line " private static JsonSerializerOptions CreateSerializerOptions()" + line " {" + line " var options = new JsonSerializerOptions(ProtocolJsonSerializerContext.Default.Options);" + line " options.TypeInfoResolver = Resolver;" + line " return options;" + line " }" + + line "" + line " internal static void Configure(JsonSerializerOptions options)" + line " {" + line " options.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;" + line " options.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;" + line " options.PropertyNameCaseInsensitive = true;" + line " options.TypeInfoResolver = Resolver;" + + line + " foreach (JsonConverter converter in ProtocolJsonSerializerContext.Default.Options.Converters) options.Converters.Add(converter);" + + line " }" + + line " internal static JsonElement Serialize(object? value)" + line " {" + + line " if (value is null) return JsonDocument.Parse(\"null\").RootElement.Clone();" + + line + " return JsonSerializer.SerializeToElement(value, SerializerOptions.GetTypeInfo(value.GetType()) ?? throw new JsonException($\"No generated JSON metadata for {value.GetType()}.\"));" + + line " }" + + line " internal static T Deserialize(JsonElement value)" + line " {" + line " ValidateRequired(typeof(T), value);" + + line + " return JsonSerializer.Deserialize(value, (JsonTypeInfo)(SerializerOptions.GetTypeInfo(typeof(T)) ?? throw new JsonException($\"No generated JSON metadata for {typeof(T)}.\")))!;" + + line " }" + line "" + line " internal static void ValidateRequired(Type type, JsonElement value)" + line " {" + + for ty in validationOptions do + let valueType = ty.GetGenericArguments().[0] + line $" if (type == typeof({csharpTypeName ty}))" + line " {" + line " if (value.ValueKind != JsonValueKind.Null)" + line " {" + line $" ValidateRequired(typeof({csharpTypeName valueType}), value);" + line " }" + line "" + line " return;" + line " }" + + for ty in validationArrays do + let elementType = ty.GetElementType() + line $" if (type == typeof({csharpTypeName ty}))" + line " {" + line " if (value.ValueKind == JsonValueKind.Array)" + line " {" + line " foreach (JsonElement element in value.EnumerateArray())" + line " {" + line $" ValidateRequired(typeof({csharpTypeName elementType}), element);" + line " }" + line " }" + line "" + line " return;" + line " }" + + for ty in validationMaps do + let valueType = ty.GetGenericArguments().[1] + line $" if (type == typeof({csharpTypeName ty}))" + line " {" + line " if (value.ValueKind == JsonValueKind.Object)" + line " {" + line " foreach (JsonProperty property in value.EnumerateObject())" + line " {" + line $" ValidateRequired(typeof({csharpTypeName valueType}), property.Value);" + line " }" + line " }" + line "" + line " return;" + line " }" + + for ty in records do + let properties = + FSharpType.GetRecordFields( + ty, + BindingFlags.Public + ||| BindingFlags.NonPublic + ) + + line $" if (type == typeof({csharpTypeName ty}))" + line " {" + + properties + |> Array.iteri (fun index property -> + let propertyName = System.Text.Json.JsonNamingPolicy.CamelCase.ConvertName(property.Name) + + let isOptional = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() = optionDefinition + + if isOptional then + line $" if (TryGetProperty(value, \"{escape propertyName}\", out JsonElement property{index}))" + + line " {" + + line $" ValidateRequired(typeof({csharpTypeName property.PropertyType}), property{index});" + + line " }" + else + line $" JsonElement property{index} = RequireProperty(value, \"{escape propertyName}\");" + + line $" ValidateRequired(typeof({csharpTypeName property.PropertyType}), property{index});" + ) + + line "" + line " return;" + line " }" + + line " }" + line "" + line " private static JsonElement RequireProperty(JsonElement value, string name)" + line " {" + line " if (TryGetProperty(value, name, out JsonElement property)) return property;" + line " throw new JsonException(string.Concat(\"Required property '\", name, \"' is missing.\"));" + line " }" + line "" + line " private static bool TryGetProperty(JsonElement value, string name, out JsonElement result)" + line " {" + line " if (value.ValueKind == JsonValueKind.Object)" + line " {" + line " foreach (JsonProperty property in value.EnumerateObject())" + line " {" + line " if (string.Equals(property.Name, name, StringComparison.OrdinalIgnoreCase))" + line " {" + line " result = property.Value;" + line " return true;" + line " }" + line " }" + line " }" + line "" + line " result = default;" + line " return false;" + line " }" + + line + " internal static Task NotifyAsync(global::StreamJsonRpc.JsonRpc rpc, string method, JsonElement value) => rpc.NotifyWithParameterObjectAsync(method, NamedArguments(value), NamedArgumentTypes(value));" + + line + " internal static Task InvokeAsync(global::StreamJsonRpc.JsonRpc rpc, string method, JsonElement value) => rpc.InvokeWithParameterObjectAsync(method, NamedArguments(value), NamedArgumentTypes(value), CancellationToken.None);" + + line "" + + line " private static IReadOnlyDictionary NamedArguments(JsonElement value)" + + line " {" + + line + " if (value.ValueKind != JsonValueKind.Object) throw new JsonException(\"Protocol parameters must be a JSON object.\");" + + line " var arguments = new Dictionary();" + + line + " foreach (JsonProperty property in value.EnumerateObject()) arguments.Add(property.Name, property.Value);" + + line " return arguments;" + line " }" + line "" + + line " private static IReadOnlyDictionary NamedArgumentTypes(JsonElement value)" + + line " {" + + line + " if (value.ValueKind != JsonValueKind.Object) throw new JsonException(\"Protocol parameters must be a JSON object.\");" + + line " var types = new Dictionary();" + + line + " foreach (JsonProperty property in value.EnumerateObject()) types.Add(property.Name, typeof(JsonElement));" + + line " return types;" + line " }" + + line "}" + line "" + line "internal static class FormatterFactory" + line "{" + + line + " [UnconditionalSuppressMessage(\"Trimming\", \"IL2026\", Justification = \"The generated JsonSerializerContext and static RPC metadata close the protocol graph.\")]" + + line + " [UnconditionalSuppressMessage(\"AOT\", \"IL3050\", Justification = \"The generated JsonSerializerContext and static RPC metadata close the protocol graph.\")]" + + line " internal static IJsonRpcMessageFormatter Create()" + line " {" + line " var formatter = new SystemTextJsonFormatter();" + line " ProtocolMetadata.Configure(formatter.JsonSerializerOptions);" + line " return formatter;" + line " }" + + line "}" + + builder.ToString() + + let private generateFSharp (metaModel: MetaModel.MetaModel) = + let serverRequests = + metaModel.Requests + |> Array.filter Proposed.checkProposed + |> Array.filter (fun request -> + request.MessageDirection = MetaModel.MessageDirection.ClientToServer + || request.MessageDirection = MetaModel.MessageDirection.Both + ) + + let serverNotifications = + metaModel.Notifications + |> Array.filter Proposed.checkProposed + |> Array.filter (fun notification -> + notification.MessageDirection = MetaModel.MessageDirection.ClientToServer + || notification.MessageDirection = MetaModel.MessageDirection.Both + ) + + let builder = StringBuilder() + + let line (text: string) = + builder.AppendLine(text) + |> ignore + + line "// " + line "namespace Ionide.LanguageServerProtocol" + line "open System" + line "open System.Text.Json" + line "open System.Threading" + line "open System.Threading.Tasks" + line "open Ionide.LanguageServerProtocol.StaticMetadata" + line "" + line "type internal StaticProtocolTarget<'server when 'server :> ILspServer>" + + line + " (server: 'server, handlings: Map>, onShutdown: Action, onExit: Action) =" + + line " inherit ProtocolTargetContract()" + + for request in serverRequests do + let name = normalizeMethod request.Method + let route = escape request.Method + + if request.Method = "shutdown" then + line $" override _.{name}Async(cancellationToken) =" + line " onShutdown.Invoke()" + + line + $" AotRuntime.invokeWithoutParameter server handlings \"{route}\" cancellationToken (fun () -> server.{name}())" + elif Array.isEmpty request.ParamsSafe then + line $" override _.{name}Async(cancellationToken) =" + + line + $" AotRuntime.invokeWithoutParameter server handlings \"{route}\" cancellationToken (fun () -> server.{name}())" + else + line $" override _.{name}Async(request, cancellationToken) =" + + line + $" AotRuntime.invokeWithParameter server handlings \"{route}\" request cancellationToken (fun parameter -> server.{name}(parameter))" + + for notification in serverNotifications do + let name = normalizeMethod notification.Method + let route = escape notification.Method + + if notification.Method = "exit" then + line $" override _.{name}Async(cancellationToken) =" + line " onExit.Invoke()" + + line + $" AotRuntime.notifyWithoutParameter server handlings \"{route}\" cancellationToken (fun () -> server.{name}())" + elif Array.isEmpty notification.ParamsSafe then + line $" override _.{name}Async(cancellationToken) =" + + line + $" AotRuntime.notifyWithoutParameter server handlings \"{route}\" cancellationToken (fun () -> server.{name}())" + else + line $" override _.{name}Async(request, cancellationToken) =" + + line + $" AotRuntime.notifyWithParameter server handlings \"{route}\" request cancellationToken (fun parameter -> server.{name}(parameter))" + + builder.ToString() + + let generate + (metaModel: MetaModel.MetaModel) + (contractAssemblyPath: string) + (csharpOutputPath: string) + (fsharpOutputPath: string) + = + async { + let assembly = Assembly.LoadFrom contractAssemblyPath + let csharp = generateCSharp assembly metaModel + let fsharp = generateFSharp metaModel + + Directory.CreateDirectory(Path.GetDirectoryName(csharpOutputPath)) + |> ignore + + Directory.CreateDirectory(Path.GetDirectoryName(fsharpOutputPath)) + |> ignore + + do! FileWriters.writeIfChanged csharpOutputPath csharp + do! FileWriters.writeIfChanged fsharpOutputPath fsharp + } \ No newline at end of file diff --git a/tools/MetaModelGenerator/GenerateClientServer.fs b/tools/MetaModelGenerator/GenerateClientServer.fs index 9e22800..33fa804 100644 --- a/tools/MetaModelGenerator/GenerateClientServer.fs +++ b/tools/MetaModelGenerator/GenerateClientServer.fs @@ -316,6 +316,12 @@ module GenerateClientServer = |> Gen.mkOak |> fun oak -> CodeFormatter.FormatOakAsync(oak, formatConfig) + let formattedText = + formattedText.Replace( + " type ServerRequestHandling<'server when 'server :> ILspServer> = { Run: 'server -> System.Delegate }", + " type ServerRequestHandling<'server when 'server :> ILspServer> =\n { Run: 'server -> System.Delegate }\n#if NET10_0\n\n override _.ToString() = \"ServerRequestHandling\"\n#endif" + ) + do! FileWriters.writeIfChanged outputPath formattedText } \ No newline at end of file diff --git a/tools/MetaModelGenerator/GenerateTypes.fs b/tools/MetaModelGenerator/GenerateTypes.fs index 1b7c733..551694b 100644 --- a/tools/MetaModelGenerator/GenerateTypes.fs +++ b/tools/MetaModelGenerator/GenerateTypes.fs @@ -971,4 +971,61 @@ See https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17 |> fun oak -> CodeFormatter.FormatOakAsync(oak, formatConfig) do! FileWriters.writeIfChanged outputPath formattedText + } + + /// Generates the net10 protocol contract without Newtonsoft.Json surface area. + /// LSP `any` slots use the JsonElement-backed LSPAny wrapper supplied by AotTypes.fs. + let generateAotType (parsedMetaModel: MetaModel.MetaModel) outputPath = + async { + let temporaryPath = + outputPath + + ".newtonsoft" + + do! generateType parsedMetaModel temporaryPath + + let! generated = + System.IO.File.ReadAllTextAsync temporaryPath + |> Async.AwaitTask + + let modern = + generated + .Replace("open Newtonsoft.Json\n", "open System.Text.Json.Serialization\n") + .Replace("open Newtonsoft.Json.Linq\n", "") + .Replace("JToken", "LSPAny") + .Replace(" []\n", "") + .Replace("[)>]\n", "") + .Replace( + "$\"{x.Start.DebuggerDisplay}-{x.End.DebuggerDisplay}\"", + "String.Concat(x.Start.DebuggerDisplay, \"-\", x.End.DebuggerDisplay)" + ) + .Replace( + "$\"({x.Line},{x.Character})\"", + "String.Concat(\"(\", x.Line.ToString(System.Globalization.CultureInfo.CurrentCulture), \",\", x.Character.ToString(System.Globalization.CultureInfo.CurrentCulture), \")\")" + ) + .Replace( + "$\"[{defaultArg x.Severity DiagnosticSeverity.Error}] ({x.Range.DebuggerDisplay}) {x.Message} ({Option.map string x.Code |> Option.defaultValue String.Empty})\"", + "let code =\n x.Code\n |> Option.map (function\n | U2.C1 value -> value.ToString(System.Globalization.CultureInfo.CurrentCulture)\n | U2.C2 value -> value)\n |> Option.defaultValue String.Empty\n\n String.Concat(\"[\", (defaultArg x.Severity DiagnosticSeverity.Error).ToString(), \"] (\", x.Range.DebuggerDisplay, \") \", x.Message, \" (\", code, \")\")" + ) + + let addRecordToString (recordMatch: System.Text.RegularExpressions.Match) = + let name = recordMatch.Groups.["name"].Value + + String.Concat(recordMatch.Groups.["declaration"].Value, " with\n\n override _.ToString() = \"", name, "\"") + + let modern = + System.Text.RegularExpressions.Regex.Replace( + modern, + "(?ms)^(?type (?[A-Za-z0-9_`]+) = \\{\n.*?^\\})(?: with)?$", + System.Text.RegularExpressions.MatchEvaluator addRecordToString + ) + + let modern = + System.Text.RegularExpressions.Regex.Replace( + modern, + "(?m)^(?type (?[A-Za-z0-9_`]+) = \\{.*\\})$", + System.Text.RegularExpressions.MatchEvaluator addRecordToString + ) + + System.IO.File.Delete temporaryPath + do! FileWriters.writeIfChanged outputPath modern } \ No newline at end of file diff --git a/tools/MetaModelGenerator/MetaModelGenerator.fsproj b/tools/MetaModelGenerator/MetaModelGenerator.fsproj index 84e04d9..4478f7c 100644 --- a/tools/MetaModelGenerator/MetaModelGenerator.fsproj +++ b/tools/MetaModelGenerator/MetaModelGenerator.fsproj @@ -6,6 +6,8 @@ + + @@ -13,6 +15,7 @@ + - \ No newline at end of file + diff --git a/tools/MetaModelGenerator/Program.fs b/tools/MetaModelGenerator/Program.fs index b69dd4c..9f90913 100644 --- a/tools/MetaModelGenerator/Program.fs +++ b/tools/MetaModelGenerator/Program.fs @@ -28,15 +28,43 @@ module Main = "The path to metaModel.json. See https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#metaModel" | OutputFilePath _ -> "The path to the output file. Should end with .fs" + type AotMetadataArgs = + | MetaModelPath of string + | ContractAssemblyPath of string + | CSharpOutputFilePath of string + | FSharpOutputFilePath of string + + interface IArgParserTemplate with + member this.Usage = + match this with + | MetaModelPath _ -> "The path to metaModel.json." + | ContractAssemblyPath _ -> "The path to the built netstandard2.0 protocol assembly." + | CSharpOutputFilePath _ -> "The generated C# metadata source path." + | FSharpOutputFilePath _ -> "The generated F# static target source path." + + type AotTypesArgs = + | MetaModelPath of string + | OutputFilePath of string + + interface IArgParserTemplate with + member this.Usage = + match this with + | MetaModelPath _ -> "The path to metaModel.json." + | OutputFilePath _ -> "The generated modern F# protocol types source path." + type CommandArgs = | [] Types of ParseResults | [] ClientServer of ParseResults + | [] AotTypes of ParseResults + | [] AotMetadata of ParseResults interface IArgParserTemplate with member this.Usage = match this with | Types _ -> "Generates Types from metaModel.json." | ClientServer _ -> "Generates Client/Server" + | AotTypes _ -> "Generates System.Text.Json-compatible protocol types." + | AotMetadata _ -> "Generates Native AOT serialization and RPC metadata." let readMetaModel metamodelPath = async { @@ -95,4 +123,28 @@ module Main = GenerateClientServer.generateClientServer metaModel OutputFilePath |> Async.RunSynchronously + | AotTypes r -> + let metaModelPath = r.GetResult <@ AotTypesArgs.MetaModelPath @> + let outputFilePath = r.GetResult <@ AotTypesArgs.OutputFilePath @> + + let metaModel = + readMetaModel metaModelPath + |> Async.RunSynchronously + + GenerateTypes.generateAotType metaModel outputFilePath + |> Async.RunSynchronously + + | AotMetadata r -> + let metaModelPath = r.GetResult <@ AotMetadataArgs.MetaModelPath @> + let contractAssemblyPath = r.GetResult <@ AotMetadataArgs.ContractAssemblyPath @> + let csharpOutputPath = r.GetResult <@ AotMetadataArgs.CSharpOutputFilePath @> + let fsharpOutputPath = r.GetResult <@ AotMetadataArgs.FSharpOutputFilePath @> + + let metaModel = + readMetaModel metaModelPath + |> Async.RunSynchronously + + GenerateAotMetadata.generate metaModel contractAssemblyPath csharpOutputPath fsharpOutputPath + |> Async.RunSynchronously + 0 \ No newline at end of file From e8341edfc53405370e3e4cd339f829ed0d3bb577 Mon Sep 17 00:00:00 2001 From: alsi-lawr <177320313+alsi-lawr@users.noreply.github.com> Date: Wed, 22 Jul 2026 16:39:28 +0100 Subject: [PATCH 2/3] fix: use reflection-free F# compilation for net10 --- src/AotJsonRpc.fs | 2 -- src/AotTypes.fs | 6 ------ src/FsLibLog.fs | 7 ++++++- src/Ionide.LanguageServerProtocol.fsproj | 5 +++-- src/TypeDefaults.fs | 11 +++++++++++ ...Ionide.LanguageServerProtocol.Tests.fsproj | 4 ++-- tools/MetaModelGenerator/GenerateTypes.fs | 19 ------------------- 7 files changed, 22 insertions(+), 32 deletions(-) diff --git a/src/AotJsonRpc.fs b/src/AotJsonRpc.fs index 0e49b21..a567537 100644 --- a/src/AotJsonRpc.fs +++ b/src/AotJsonRpc.fs @@ -18,8 +18,6 @@ type Error = { Data: LSPAny option } with - override _.ToString() = "Language server protocol error" - static member Create(code: int, message: string) = { Code = code; Message = message; Data = None } static member ParseError(?message) = Error.Create(int Types.ErrorCodes.ParseError, defaultArg message "Parse error") diff --git a/src/AotTypes.fs b/src/AotTypes.fs index 5abef2c..2db8c16 100644 --- a/src/AotTypes.fs +++ b/src/AotTypes.fs @@ -17,16 +17,12 @@ type U2<'T1, 'T2> = | C1 of 'T1 | C2 of 'T2 - override _.ToString() = "U2" - [] type U3<'T1, 'T2, 'T3> = | C1 of 'T1 | C2 of 'T2 | C3 of 'T3 - override _.ToString() = "U3" - [] type U4<'T1, 'T2, 'T3, 'T4> = | C1 of 'T1 @@ -34,8 +30,6 @@ type U4<'T1, 'T2, 'T3, 'T4> = | C3 of 'T3 | C4 of 'T4 - override _.ToString() = "U4" - /// A JSON value carried in an LSP `any` slot. [] type LSPAny private (element: JsonElement) = diff --git a/src/FsLibLog.fs b/src/FsLibLog.fs index f14de48..0b5dc09 100644 --- a/src/FsLibLog.fs +++ b/src/FsLibLog.fs @@ -803,7 +803,12 @@ module LogProvider = // | Call (_, methInfo, _) -> sprintf "%s.%s" methInfo.DeclaringType.FullName methInfo.Name // | Lambda(_, expr) -> getModuleType expr // | ValueWithName(_,_,instance) -> instance - | x -> failwithf "Expression is not a property. %A" x + | x -> +#if NET10_0 + failwith $"Expression is not a property. {x}" +#else + failwithf "Expression is not a property. %A" x +#endif /// **Description** /// diff --git a/src/Ionide.LanguageServerProtocol.fsproj b/src/Ionide.LanguageServerProtocol.fsproj index 086df4b..f98f0c8 100644 --- a/src/Ionide.LanguageServerProtocol.fsproj +++ b/src/Ionide.LanguageServerProtocol.fsproj @@ -12,9 +12,10 @@ https://github.com/ionide/LanguageServerProtocol 3.17.0 true + $(OtherFlags) --reflectionfree -