diff --git a/.github/workflows/Test.yml b/.github/workflows/Test.yml index d769ab27..d1001243 100644 --- a/.github/workflows/Test.yml +++ b/.github/workflows/Test.yml @@ -12,7 +12,7 @@ jobs: - uses: ocaml/setup-ocaml@v2 with: ocaml-compiler: 5.1.1 - - run: opam install dune + - run: opam install dune yojson uri - run: opam exec -- dune build - run: opam exec -- dune install - run: eval $(opam env) && ./test.sh dbl ./test/test_suite diff --git a/src/InterpLib/Error.ml b/src/InterpLib/Error.ml index f38ae783..2fa6e2b4 100644 --- a/src/InterpLib/Error.ml +++ b/src/InterpLib/Error.ml @@ -24,7 +24,7 @@ let color_printer_generator color s = then s else TextRangePrinting.color_string color s -let report ?pos ~cls msg = +let report_to_stderr ?pos ~cls msg = let module Color = TextRangePrinting in let name, color = match cls with @@ -56,6 +56,12 @@ let report ?pos ~cls msg = | None, _ -> Printf.eprintf "%s: %s\n" name msg +let report_impl = ref report_to_stderr + +let set_report_function f = report_impl := f + +let report ?pos ~cls msg = !report_impl ?pos ~cls msg + let assert_no_error () = if !err_counter <> 0 then raise Fatal_error diff --git a/src/InterpLib/Error.mli b/src/InterpLib/Error.mli index 6361714d..f8846b20 100644 --- a/src/InterpLib/Error.mli +++ b/src/InterpLib/Error.mli @@ -24,6 +24,11 @@ type error_class = (** Report the error *) val report : ?pos:Position.t -> cls:error_class -> string -> unit +(** Set a custom function to intercept reports. + All reports will be made to this function. Used by the language server. *) +val set_report_function : + (?pos:Position.t -> cls:error_class -> string -> unit) -> unit + (** Abort compilation if any error was reported. Should be called at the end of each phase. *) val assert_no_error : unit -> unit diff --git a/src/Lsp/Connection.ml b/src/Lsp/Connection.ml new file mode 100644 index 00000000..5fd29bc2 --- /dev/null +++ b/src/Lsp/Connection.ml @@ -0,0 +1,69 @@ +(* This file is part of DBL, released under MIT license. + * See LICENSE for details. + *) + +(** Base HTTP-like protocol *) + +(** The JSON-RPC messages are sent over this simple protocol. + It consists of a header and a content part, separated by "\r\n": + header1: value1\r\n + header2: value2\r\n + \r\n + [JSON-RPC message] + The only recognized headers are Content-Length and Content-Type + and there is really one possible value for Content-Type, + so we don't care about that. *) + +open In_channel +open Out_channel + +exception Connection_error of string + +type headers = + { content_length: int option + ; content_type: string option + } + +let parse_header headers line = + match String.split_on_char ':' line |> List.map String.trim with + | ["Content-Length"; value] -> begin + match int_of_string_opt value with + | None -> raise (Connection_error ("Invalid Content-Length value: " ^ line)) + | Some len -> { headers with content_length = Some len } + end + | ["Content-Type"; value] -> { headers with content_type = Some value } + | _ -> headers + +let rec collect_header_lines (ic: in_channel) = + match input_line ic with + | None -> raise (Connection_error "Unexpected end of input") + | Some "\r" -> [] + | Some line -> line :: collect_header_lines ic + +let receive_headers (ic: in_channel) = + let headers = collect_header_lines ic in + List.fold_left parse_header + { content_length = None; content_type = None } + headers + +let receive_body (ic: in_channel) len = + match really_input_string ic len with + | None -> raise (Connection_error "Unexpected end of input") + | Some body -> body + +let receive_string (ic: in_channel) = + let headers = receive_headers ic in + match headers.content_length with + | None -> raise (Connection_error "Missing Content-Length header") + | Some len -> receive_body ic len + +let output_line (oc: out_channel) message = + let line = message ^ "\r\n" in + output_string oc line + +let send_string (oc: out_channel) message_string = + let length = String.length message_string in + output_line oc ("Content-Length: " ^ string_of_int length); + output_line oc ""; + output_string oc message_string; flush oc + diff --git a/src/Lsp/Connection.mli b/src/Lsp/Connection.mli new file mode 100644 index 00000000..d038422f --- /dev/null +++ b/src/Lsp/Connection.mli @@ -0,0 +1,15 @@ +(* This file is part of DBL, released under MIT license. + * See LICENSE for details. + *) + +(** Base HTTP-like protocol *) + +(** An exception indicating a problem with the connection, + e.g. unexpected EOF or invalid headers *) +exception Connection_error of string + +(** Receive a message from the client *) +val receive_string : in_channel -> string + +(** Send a message with the specified content to the client *) +val send_string : out_channel -> string -> unit diff --git a/src/Lsp/JsonRpc.ml b/src/Lsp/JsonRpc.ml new file mode 100644 index 00000000..8bab2020 --- /dev/null +++ b/src/Lsp/JsonRpc.ml @@ -0,0 +1,73 @@ +(* This file is part of DBL, released under MIT license. + * See LICENSE for details. + *) + +(** Main loop. Get a message, handle it, send response. *) + +open Connection +open Message + +(* ------------------------------------------------------------------------- *) +(* Auxiliary functions doing type -> json -> string conversion + and sending it to the output *) + +let send_json state json = + let string = Yojson.Safe.to_string json in + send_string (State.out_channel state) string + +let send_response state response = + send_json state (json_of_response response) + +let send_message state message = + send_json state (json_of_message message) + +let send_notification state notification = + let message = message_of_notification notification in + send_message state message + +let send_error state ?id code message = + let error = make_response_error ~code ~message () |> Result.error in + let response = make_response ?id ~result_or_error:error () in + send_response state response + +(* ------------------------------------------------------------------------- *) +(* Main function *) + +let rec run state handle_request handle_notification = + + (* Handle messages in a loop *) + let rec loop state = + let string = receive_string (State.in_channel state) in + match message_of_string string with + (* There was an error parsing the message *) + | Error (code, msg) -> + send_error state code msg; + loop state + (* The message is a notification *) + | Ok ({ id = None; _ } as message) -> begin + match notification_of_message message with + | Ok notification -> + let state = handle_notification state notification in + loop state + (* There was an error interpreting the message as a notification. + Because notifications don't get a response, we ignore it + (according to the specification). *) + | Error _ -> + loop state + end + (* The message is a request *) + | Ok ({ id = Some id; _ } as message) -> begin + match request_of_message message with + | Ok request -> + let state, result = handle_request state request in + let response = make_response ~id ~result_or_error:result () in + send_response state response; + loop state + (* There was an error interpreting the message as a request *) + | Error (code, msg) -> + send_error state ~id code msg; + loop state + end + + in loop state + diff --git a/src/Lsp/JsonRpc.mli b/src/Lsp/JsonRpc.mli new file mode 100644 index 00000000..9f7f7e64 --- /dev/null +++ b/src/Lsp/JsonRpc.mli @@ -0,0 +1,21 @@ +(* This file is part of DBL, released under MIT license. + * See LICENSE for details. + *) + +(** Main loop. Get a message, handle it, send response. *) + +open Message + +(** Send a notification to the client *) +val send_notification : State.t -> server_notification -> unit + +(** Main loop of the server. The fuction expects: + - the initial state + - a handler for requests + - a handler for notifications *) +val run : + State.t -> + (State.t -> request -> State.t * (server_result, response_error) result) -> + (State.t -> client_notification -> State.t) -> + 'bottom + diff --git a/src/Lsp/Message.ml b/src/Lsp/Message.ml new file mode 100644 index 00000000..08890da7 --- /dev/null +++ b/src/Lsp/Message.ml @@ -0,0 +1,564 @@ +(* This file is part of DBL, released under MIT license. + * See LICENSE for details. + *) + +(** JSON <--> OCaml translation of messages *) + +(** For each type LspType defined in the LSP specification this file contains: + - a corresponding OCaml type lsp_type + - a subset of the following: + * a function converting from json to lsp_type, + named to_lsp_type (to match Yojson naming convention). + * a function converting from lsp_type to json, + named from_lsp_type (exception: json_of_message and json_of_response, + which are accessible from outside) + * a smart constructor named make_lsp_type. + * additional functions converting from other_type to lsp_type, + named lsp_type_of_other_type. + + Spec: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/ +*) + +open Yojson.Safe.Util +open Either + +let ok = Result.ok + +(* ------------------------------------------------------------------------- *) +type json = Yojson.Safe.t + +(** Make an Assoc (JSON object), without nulls *) +let make_assoc xs = `Assoc (List.filter (fun (_, v) -> v <> `Null) xs) + +let from_option (f : 'a -> json) option = + match option with + | None -> `Null + | Some x -> f x + +let from_int (n : int) = `Int n + +let from_string (s : string) = `String s + +let from_list f xs = `List (List.map f xs) + +let from_json (json : json) = json + +(* ------------------------------------------------------------------------- *) +type uri = Uri.t + +let to_uri json = json |> to_string |> Uri.of_string + +let from_uri uri = uri |> Uri.to_string |> from_string + +(* ------------------------------------------------------------------------- *) +type position = { + line : int; + character : int; +} + +let to_position json = + let line = json |> member "line" |> to_int in + let character = json |> member "character" |> to_int in + { line; character } + +let from_position position = + make_assoc [ + ("line", from_int position.line); + ("character", from_int position.character); + ] + +(* ------------------------------------------------------------------------- *) +type range = { + start : position; + end_ : position (* json key: end *); +} + +let to_range json = + let start = json |> member "start" |> to_position in + let end_ = json |> member "end" |> to_position in + { start; end_ } + +let from_range range = + make_assoc [ + ("start", from_position range.start); + ("end", from_position range.end_); + ] + +let range_of_position (pos : Position.t) = + match pos.pos_fname with + | "" -> + { + start = { + line = 0; + character = 0; + }; + end_ = { + line = 0; + character = 0; + } + } + | _ -> + { + start = { + line = pos.pos_start_line - 1; + character = Position.start_column pos - 1 + }; + end_ = { + line = pos.pos_end_line - 1; + character = Position.end_column pos + }; + } + +(* ------------------------------------------------------------------------- *) +type text_document_identifier = { + uri: uri +} + +let to_text_document_identifier json = + let uri = json |> member "uri" |> to_uri in + { uri } + +(* ------------------------------------------------------------------------- *) +type progress_token = (int, string) Either.t + +let to_progress_token_option json = + match json with + | `Null -> None + | `Int token -> Some (Left token) + | `String token -> Some (Right token) + | _ -> raise (Type_error ("invalid progress token type", json)) + +(* ------------------------------------------------------------------------- *) +type hover_params = { + text_document : text_document_identifier; + position : position; + work_done_token : progress_token option; +} + +let to_hover_params json = + let text_document = + json |> member "textDocument" |> to_text_document_identifier in + let position = + json |> member "position" |> to_position in + let work_done_token = + json |> member "workDoneToken" |> to_progress_token_option in + { text_document; position; work_done_token } + +(* ------------------------------------------------------------------------- *) +type text_document_item = { + uri : uri; + language_id : string; + version : int; + text : string +} + +let to_text_document_item json = + let uri = json |> member "uri" |> to_uri in + let language_id = json |> member "languageId" |> to_string in + let version = json |> member "version" |> to_int in + let text = json |> member "text" |> to_string in + { uri; language_id; version; text } + +(* ------------------------------------------------------------------------- *) +type did_open_params = { + text_document: text_document_item +} + +let to_did_open_params json = + let text_document = json |> member "textDocument" |> to_text_document_item in + { text_document } + +(* ------------------------------------------------------------------------- *) +type versioned_text_document_identifier = { + version : int; + uri : uri +} + +let to_versioned_text_document_identifier json = + let version = json |> member "version" |> to_int in + let uri = json |> member "uri" |> to_uri in + { version; uri } + +(* ------------------------------------------------------------------------- *) +type text_document_content_change_event = { + range : range option; + text : string +} + +let to_text_document_content_change_event json = + let range = json |> member "range" |> to_option to_range in + let text = json |> member "text" |> to_string in + { range; text } + +(* ------------------------------------------------------------------------- *) +type did_change_params = { + text_document : versioned_text_document_identifier; + content_changes : text_document_content_change_event list; +} + +let to_did_change_params json = + let text_document = + json |> member "textDocument" |> to_versioned_text_document_identifier in + let content_changes = + json |> member "contentChanges" |> to_list + |> List.map to_text_document_content_change_event in + { text_document; content_changes } + +(* ------------------------------------------------------------------------- *) +type did_close_params = { + text_document : text_document_identifier +} + +let to_did_close_params json = + let text_document = + json |> member "textDocument" |> to_text_document_identifier in + { text_document } + +(* ------------------------------------------------------------------------- *) +type location = { + uri : uri; + range : range; +} + +let from_location loc = + make_assoc [ + ("uri", from_uri loc.uri); + ("range", from_range loc.range); + ] + +(* ------------------------------------------------------------------------- *) +type diagnostic_severity = + | Error (* 1 *) + | Warning (* 2 *) + | Information (* 3 *) + | Hint (* 4 *) + +let from_diagnostic_severity severity = + match severity with + | Error -> from_int 1 + | Warning -> from_int 2 + | Information -> from_int 3 + | Hint -> from_int 4 + +(** Translate our internal error_class type to diagnostic_severity *) +let diagnostic_severity_of_error_class cls : diagnostic_severity = + let open InterpLib.Error in + match cls with + | FatalError -> Error + | Error -> Error + | Warning -> Warning + | Note -> Information + +(* ------------------------------------------------------------------------- *) +type diagnostic_tag = + | Unnecessary (* 1 *) + | Deprecated (* 2 *) + +let from_diagnostic_tag tag = + match tag with + | Unnecessary -> from_int 1 + | Deprecated -> from_int 2 + +(* ------------------------------------------------------------------------- *) +type diagnostic_related_information = { + location : location; + message : string; +} + +let from_diagnostic_related_information ri = + make_assoc [ + ("location", from_location ri.location); + ("message", from_string ri.message); + ] + +(* ------------------------------------------------------------------------- *) +type code_description = { + href : string; +} + +let from_code_description cd = + make_assoc [ + ("href", from_string cd.href); + ] + +(* ------------------------------------------------------------------------- *) +type diagnostic = { + range : range; + severity : diagnostic_severity option; + code : (int, string) Either.t option; + code_description : code_description option; + source : string option; + message : string; + tags : diagnostic_tag option; + related_information : diagnostic_related_information list; + data : json option; +} + +let make_diagnostic + ?severity ?code ?code_description ?source ?tags ?related_information ?data + ~range ~message () = + { + range; + severity; + code; + code_description; + source; + message; + tags; + related_information = related_information |> Option.value ~default:[]; + data; + } + +let from_diagnostic d = + let from_code code = + match code with + | Left n -> from_int n + | Right s -> from_string s + in + make_assoc [ + ("range", from_range d.range); + ("severity", from_option from_diagnostic_severity d.severity); + ("code", from_option from_code d.code); + ("codeDescription", from_option from_code_description d.code_description); + ("source", from_option from_string d.source); + ("message", from_string d.message); + ("tags", from_option from_diagnostic_tag d.tags); + ("relatedInformation", from_list from_diagnostic_related_information d.related_information); + ("data", from_option from_json d.data); + ] + +(* ------------------------------------------------------------------------- *) +type publish_diagnostics_params = { + uri : uri; + version : int option; + diagnostics : diagnostic list; +} + +let make_publish_diagnostics_params ?version ~uri ~diagnostics () = + { + uri; + version; + diagnostics; + } + +let from_publish_diagnostics_params params = + make_assoc [ + ("uri", from_uri params.uri); + ("version", from_option from_int params.version); + ("diagnostics", from_list from_diagnostic params.diagnostics); + ] + +(* ------------------------------------------------------------------------- *) +type markup_kind = + | Plaintext (* "plaintext" *) + | Markdown (* "markdown" *) + +let from_markup_kind kind = + match kind with + | Plaintext -> from_string "plaintext" + | Markdown -> from_string "markdown" + +(* ------------------------------------------------------------------------- *) +type markup_content = { + kind : markup_kind; + value : string; +} + +let from_markup_content content = + make_assoc [ + ("kind", from_markup_kind content.kind); + ("value", from_string content.value); + ] + +(* ------------------------------------------------------------------------- *) +type hover_result = { + contents : markup_content; + range : range option; +} + +let from_hover_result result = + make_assoc [ + ("contents", from_markup_content result.contents); + ("range", from_option from_range result.range); + ] + +(* ------------------------------------------------------------------------- *) +type error_code = + | ParseError (* -32700 *) + | InvalidRequest (* -32600 *) + | MethodNotFound (* -32601 *) + | InvalidParams (* -32602 *) + | InternalError (* -32603 *) + | ServerNotInitialized (* -32002 *) + +let from_error_code code = + match code with + | ParseError -> from_int (-32700) + | InvalidRequest -> from_int (-32600) + | MethodNotFound -> from_int (-32601) + | InvalidParams -> from_int (-32602) + | InternalError -> from_int (-32603) + | ServerNotInitialized -> from_int (-32002) + +(* ------------------------------------------------------------------------- *) +type initialize_result = json + +(* ------------------------------------------------------------------------- *) +type server_result = + | Initialize of initialize_result + | Shutdown + (* | Hover of hover_result option *) + +let from_server_result result = + match result with + | Initialize result -> result + | Shutdown -> `Null + (* | Hover result -> from_option from_hover_result result *) + +(* ------------------------------------------------------------------------- *) +type id = (int, string) Either.t + +let from_id id = + match id with + | Left id -> from_int id + | Right id -> from_string id + +let to_id json = + match json with + | `Null -> None + | `Int id -> Some (Left id) + | `String id -> Some (Right id) + | _ -> raise (Type_error ("invalid id type", json)) + +(* ------------------------------------------------------------------------- *) +(* notification or request *) +type message = { + jsonrpc : string; + id : id option; + method_name : string (* json key: method *); + params : json; +} + +let make_message ?id ~method_name ~params () = + { jsonrpc = "2.0"; id; method_name; params } + +let message_of_string string = + try + let json = Yojson.Safe.from_string string in + let jsonrpc = json |> member "jsonrpc" |> to_string in + let id = json |> member "id" |> to_id in + let method_name = json |> member "method" |> to_string in + let params = json |> member "params" in + Ok { jsonrpc; id; method_name; params } + with + | Yojson.Json_error err -> Error (ParseError, err) + | Type_error (err, _) -> Error (InvalidRequest, err) + +let json_of_message message = + make_assoc [ + ("jsonrpc", from_string message.jsonrpc); + ("id", from_option from_id message.id); + ("method", from_string message.method_name); + ("params", message.params) + ] + +(* ------------------------------------------------------------------------- *) +(** Most notifications have an assigned direction: + from client to server (client_notification) + or from server to client (server_notification). + However, some notifications can be sent both ways. In such case just make + the same constructor in both types. *) + +type client_notification = + | Initialized + | Exit + | DidOpen of did_open_params + | DidChange of did_change_params + | DidClose of did_close_params + +let notification_of_message { method_name; params; _ } = + try + match method_name with + | "initialized" -> Initialized |> ok + | "exit" -> Exit |> ok + | "textDocument/didOpen" -> DidOpen (to_did_open_params params) |> ok + | "textDocument/didChange" -> DidChange (to_did_change_params params) |> ok + | "textDocument/didClose" -> DidClose (to_did_close_params params) |> ok + | _ -> Error (MethodNotFound, method_name) + with + | Type_error (err, _) -> Error (InvalidParams, err) + +(* ------------------------------------------------------------------------- *) +type server_notification = + | PublishDiagnostics of publish_diagnostics_params + +let method_name_of_notification notification = + match notification with + | PublishDiagnostics _ -> "textDocument/publishDiagnostics" + +let json_of_notification notification = + match notification with + | PublishDiagnostics params -> from_publish_diagnostics_params params + +let message_of_notification notification = + let method_name = method_name_of_notification notification in + let params = json_of_notification notification in + make_message ~method_name ~params () + +(* ------------------------------------------------------------------------- *) +type request = + | Initialize (* of initialize_params *) + | Shutdown + (* | Hover of hover_params *) + +let request_of_message { method_name; params; _ } = + try + match method_name with + | "initialize" -> Initialize |> ok + | "shutdown" -> Shutdown |> ok + (* | "textDocument/hover" -> Hover (to_hover_params params) |> ok *) + | _ -> Error (MethodNotFound, "Method not found: " ^ method_name) + with + | Type_error (err, _) -> Error (InvalidParams, err) + +(* ------------------------------------------------------------------------- *) +type response_error = { + code : error_code; + message : string; + data : json option; +} + +let make_response_error ?data ~code ~message () = + { code; message; data } + +let from_response_error error = + make_assoc [ + ("code", from_error_code error.code); + ("message", from_string error.message); + ("data", from_option from_json error.data); + ] + +(* ------------------------------------------------------------------------- *) +type response = { + jsonrpc : string; + id : (int, string) Either.t option; + result_or_error : (server_result, response_error) result; +} + +let make_response ?id ~result_or_error () = + { jsonrpc = "2.0"; id; result_or_error } + +let json_of_response response = + match response.result_or_error with + (* Do not use make_assoc. + id and either result or error fields are required even if null *) + | Ok result -> + `Assoc [ + ("id", from_option from_id response.id); + ("result", from_server_result result); + ] + | Error error -> + `Assoc [ + ("id", from_option from_id response.id); + ("error", from_response_error error); + ] diff --git a/src/Lsp/Message.mli b/src/Lsp/Message.mli new file mode 100644 index 00000000..fccb61a5 --- /dev/null +++ b/src/Lsp/Message.mli @@ -0,0 +1,224 @@ +(* This file is part of DBL, released under MIT license. + * See LICENSE for details. + *) + +(** JSON <--> OCaml translation of messages *) + +(** This module contains OCaml definitions of types defined + in the LSP specification, together with needed conversion functions + and smart constructors. *) + +type json = Yojson.Safe.t + +type uri = Uri.t + +type position = { + line : int; + character : int; +} + +type range = { + start : position; + end_ : position (* json key: end *); +} + +val range_of_position : Position.t -> range + +type text_document_identifier = { + uri: uri +} + +type progress_token = (int, string) Either.t + +type hover_params = { + text_document : text_document_identifier; + position : position; + work_done_token : progress_token option; +} + +type text_document_item = { + uri : uri; + language_id : string; + version : int; + text : string +} + +type did_open_params = { + text_document: text_document_item +} + +type versioned_text_document_identifier = { + version : int; + uri : uri +} + +type text_document_content_change_event = { + range : range option; + text : string +} + +type did_change_params = { + text_document : versioned_text_document_identifier; + content_changes : text_document_content_change_event list; +} + +type did_close_params = { + text_document : text_document_identifier +} + +type location = { + uri : uri; + range : range; +} + +type diagnostic_severity = + | Error + | Warning + | Information + | Hint + +(* Translate our internal error_class type to diagnostic_severity *) +val diagnostic_severity_of_error_class : + InterpLib.Error.error_class -> diagnostic_severity + +type diagnostic_tag = + | Unnecessary + | Deprecated + +type diagnostic_related_information = { + location : location; + message : string; +} + +type code_description = { + href : string; +} + +type diagnostic = { + range : range; + severity : diagnostic_severity option; + code : (int, string) Either.t option; + code_description : code_description option; + source : string option; + message : string; + tags : diagnostic_tag option; + related_information : diagnostic_related_information list; + data : json option; +} + +val make_diagnostic : + ?severity:diagnostic_severity -> + ?code:(int, string) Either.t -> + ?code_description:code_description -> + ?source:string -> + ?tags:diagnostic_tag -> + ?related_information:diagnostic_related_information list -> + ?data:json -> + range:range -> + message:string -> + unit -> + diagnostic + +type publish_diagnostics_params = { + uri : uri; + version : int option; + diagnostics : diagnostic list; +} + +val make_publish_diagnostics_params : + ?version:int -> + uri:uri -> + diagnostics:diagnostic list -> + unit -> + publish_diagnostics_params + +type markup_kind = + | Plaintext + | Markdown + +type markup_content = { + kind : markup_kind; + value : string; +} + +type hover_result = { + contents : markup_content; + range : range option; +} + +type error_code = + | ParseError + | InvalidRequest + | MethodNotFound + | InvalidParams + | InternalError + | ServerNotInitialized + +type initialize_result = json + +type server_result = + | Initialize of initialize_result + | Shutdown + +type id = (int, string) Either.t + +type message = { + jsonrpc : string; + id : id option; + method_name : string (* json key: method *); + params : json; +} + +val make_message : + ?id:id -> + method_name:string -> + params:json -> + unit -> + message + +val message_of_string : string -> (message, error_code * string) result +val json_of_message : message -> json + +type client_notification = + | Initialized + | Exit + | DidOpen of did_open_params + | DidChange of did_change_params + | DidClose of did_close_params + +val notification_of_message : + message -> (client_notification, error_code * string) result + +type server_notification = + | PublishDiagnostics of publish_diagnostics_params + +val message_of_notification : server_notification -> message + +type request = + | Initialize + | Shutdown + +val request_of_message : message -> (request, error_code * string) result + +type response_error = { + code : error_code; + message : string; + data : json option; +} + +val make_response_error : + ?data:json -> code:error_code -> message:string -> unit -> response_error + +type response = { + jsonrpc : string; + id : (int, string) Either.t option; + result_or_error : (server_result, response_error) result; +} + +val make_response : + ?id:(int, string) Either.t -> + result_or_error:(server_result, response_error) result -> + unit -> + response + +val json_of_response : response -> json diff --git a/src/Lsp/README.md b/src/Lsp/README.md new file mode 100644 index 00000000..36da00a5 --- /dev/null +++ b/src/Lsp/README.md @@ -0,0 +1,136 @@ +framls +====== + +`framls` is a language server for Fram. This document is meant to be +an entry point for someone wanting to contribute to `framls`. +If you want to configure `framls` for use with your code editor please visit +[fram-lang.org](https://fram-lang.org). + +Building +-------- + +`framls` is built together with DBL when running `dune build` and is +available as `framls` binary. + +Basics +------ + +In LSP, client communicates with the server by sending JSON-RPC messages over +a Base protocol. + +A simple diagram is shown below. +``` + Text/JSON ┆ OCaml + ┆ + ┌────┴─────┐ + │Message.ml│ + └──↑─┬─↑───┘ + | ┆ | +┌──────┐ ┌───────────────┐ ┌───↓─┼─↓────┐ ┌───────────┐ ┌───┐ +│Client│<-->│ Base │<-->│ JSON-RPC │<-->│ framls │<-->│DBL│ +└──────┘ │(Connection.ml)│ │(JsonRpc.ml)│ │(framls.ml)│ └───┘ + └───────────────┘ └─────┼──────┘ └───────────┘ + ┆ + +``` + +Below is a basic explanation of relevant protocols/modules. +Each module also contains a doc comment explaining more or less the same. + +Useful links are the +[JSON-RPC specification](https://www.jsonrpc.org/specification) +and the +[LSP specification](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/) + +Base +---- + +Base is a simple HTTP-like protocol. It consists of a header and content part, +separated by "\r\n": +``` +header1: value1\r\n +header2: value2\r\n +\r\n +[JSON-RPC message] +``` + +It is implemented in the `Connection` module. + +JSON-RPC +-------- + +JSON-RPC is an RPC protocol using JSON as data format. +```json +{ + "jsonrpc": "2.0", + "id": 1, + "method": "textDocument/completion", + "params": { + "param1": "value1", + } +} +``` + +JSON-RPC messages are divided into +* Requests - sent by the client. They require a Response. +* Responses - sent by the server after processing a Request. +* Notifications - depending on the type can be sent by the server or client. + They work like live events and don't get a Response. + +It is implemented in the `JsonRpc` module and uses the `Message` module +to translate between JSON and OCaml. It also contains the main loop of +the server. + +framls (module) +--------------- + +The `framls` module handles incoming messages, communicating with +the DBL typechecker. + +Implementing new messages +------------------------- + +This section explains how to extend the functionality of the server by +a new message. + +1. Extend `Message` + 1. Define the params of the message as a new type + as explained in the doc comment at the top of the module. + 2. If the message is a + * Request - add constructors to `request` and `server_result` + and extend `request_of_message` and `from_server_result` + * Notification sent by the client - + add a constructor to `client_notification` + and extend `notification_of_message` + * Notification sent by the server - + add a constructor to `server_notification` + and extend `method_name_of_notification` and `json_of_notification` +2. Extend `framls` and possibly `State`. + Add the logic and extend `handle_notification` or `handle_request`. + +You should not need to change `JsonRpc` or `Connection`. + +Debugging +--------- + +There is no logging implemented. To see the messages that are being sent +follow these steps: +1. Choose your favorite port number and substitute it for `5555` below. +2. Configure your editor to use `nc localhost 5555` as the language server +for Fram. +3. Create two named pipes +```bash +mkfifo in +mkfifo out +``` +4. If you want to see outputs of both client and server run +```bash +cat in | framls | tee -p out & +cat out | nc -l 5555 | tee -p in & +``` +5. If you want to see only the output of the server run +```bash +cat in | framls | tee -p out & +cat out | nc -l 5555 > in & +``` +6. Run the editor. diff --git a/src/Lsp/State.ml b/src/Lsp/State.ml new file mode 100644 index 00000000..814e7be6 --- /dev/null +++ b/src/Lsp/State.ml @@ -0,0 +1,65 @@ +(* This file is part of DBL, released under MIT license. + * See LICENSE for details. + *) + +(** Server state *) + +module UriMap = Map.Make(Uri) + +type document = { + temp_path: string; +} + +type connection = { + in_channel: in_channel; + out_channel: out_channel; +} + +type t = { + connection: connection; + documents: document UriMap.t +} + +let create ~in_channel ~out_channel = + { + connection = { in_channel; out_channel }; + documents = UriMap.empty; + } + +let out_channel { connection = { out_channel; _ }; _ } = out_channel +let in_channel { connection = { in_channel; _ }; _ } = in_channel + +let open_document uri content state = + let temp_path = Filename.temp_file "" DblConfig.src_extension in + Out_channel.with_open_text temp_path (fun oc -> + output_string oc content + ); + let document = { temp_path } in + { state with documents = UriMap.add uri document state.documents } + +let update_document uri new_content state = + (* Only open documents can be updated. If uri is not found then we want + to fail because it's more likely to be an error on our side. *) + let doc = UriMap.find uri state.documents in + (* overwrites the file if it exists *) + Out_channel.with_open_text doc.temp_path (fun oc -> + output_string oc new_content + ); state + +let close_document uri state = + (* Same as above. *) + let doc = UriMap.find uri state.documents in + Sys.remove doc.temp_path; + { state with documents = UriMap.remove uri state.documents } + +let close_all_documents state = + UriMap.iter (fun _ doc -> Sys.remove doc.temp_path) state.documents; + { state with documents = UriMap.empty } + +let get_document_path uri state = + (* The client might send requests involving closed files, so we don't fail. + See State.mli for details. *) + match UriMap.find_opt uri state.documents with + | None -> Uri.path uri + | Some doc -> doc.temp_path + diff --git a/src/Lsp/State.mli b/src/Lsp/State.mli new file mode 100644 index 00000000..fd677749 --- /dev/null +++ b/src/Lsp/State.mli @@ -0,0 +1,48 @@ +(* This file is part of DBL, released under MIT license. + * See LICENSE for details. + *) + +(** Server state *) + +(** The LSP client claims ownership of files edited by the user (by sending + a `textDocument/didOpen` notification). That means the content of the file + is managed by the client and shouldn't be read from disk because the user + might not have saved it yet. The client informs us of the changes in the file + with a `textDocument/didChange` notification. We keep our own copy of + the file in a temporary location and update it based on those notifications. + The file is closed with a `textDocument/didClose` notification. + + Note from the spec: a server’s ability to fulfill requests is independent + of whether a text document is open or closed. + Spec: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_didOpen +*) + +type t + +(** Create a new state *) +val create : in_channel:in_channel -> out_channel:out_channel -> t + +(** Access the channels *) +val out_channel : t -> out_channel +val in_channel : t -> in_channel + +(** Open a new document with the given content. + This creates a new temporary file associated with the specified uri. *) +val open_document : Uri.t -> string -> t -> t + +(** Update an open document. + This updates the temporary file. *) +val update_document : Uri.t -> string -> t -> t + +(** Close a document. + This removes the temporary file. *) +val close_document : Uri.t -> t -> t + +(** Close all documents. + This removes all of the temporary files. *) +val close_all_documents : t -> t + +(** Get a path to the file associated with the specified uri. + If the client has opened the file, this returns the path to the temp file; + otherwise, this returns the same path that the uri points to. *) +val get_document_path : Uri.t -> t -> string diff --git a/src/Lsp/dune b/src/Lsp/dune new file mode 100644 index 00000000..d022bc34 --- /dev/null +++ b/src/Lsp/dune @@ -0,0 +1,5 @@ +(executable + (name framls) + (modes byte exe) + (public_name framls) + (libraries yojson uri dblParser interpLib typeInference toCore)) diff --git a/src/Lsp/framls.ml b/src/Lsp/framls.ml new file mode 100644 index 00000000..7697d096 --- /dev/null +++ b/src/Lsp/framls.ml @@ -0,0 +1,115 @@ +(* This file is part of DBL, released under MIT license. + * See LICENSE for details. + *) + +(** Main module of the server. Handle messages from the client. *) + +open Message +open JsonRpc +open Result +open Position + +(** Response for the Initialize request *) +let initialize_params = + (* Maybe this should be handled more gracefully. Every capability supported + by us must be declared here and currently it's easy to forget about this. + Instead of the big match in handle_request and handle_notification, + each capability could be it's own module defining a part of this json + which would then be registered somehow but that might be an overkill. *) + Yojson.Safe.from_string + {| + { + "capabilities": { + "textDocumentSync": { + "openClose": true, + "change": 1 + } + }, + "serverInfo": { + "name": "framls" + } + } + |} + +(** Make DBL correctly find modules *) +let set_module_dirs fname = + DblConfig.lib_search_dirs := [DblConfig.stdlib_path]; + let cur_dir = Filename.dirname fname in + DblConfig.local_search_dirs := [cur_dir] + +(** Custom report function used to gather reports made by the type-checker. + The report messages sometimes contain a reference to a file + ("type ... is defined at "), so we make sure to replace + the temporary path with the real path of the file the user is editing. *) +let report temp_path real_path diags ?pos ~cls message = + let open InterpLib.Error in + let temp_path_re = Str.regexp_string temp_path in + match pos with + | Some pos when pos.pos_fname = temp_path || pos = nowhere -> + let severity = diagnostic_severity_of_error_class cls in + let range = range_of_position pos in + let message = Str.global_replace temp_path_re real_path message in + let diag = make_diagnostic ~range ~severity ~message () in + diags := diag :: !diags + | _ -> () + +(** Type-check a file. It will report all errors to our report function, + so we don't need the result. *) +let type_check path = + try + DblParser.Main.parse_file ~use_prelude:true path + |> TypeInference.Main.tr_program + |> ToCore.Main.tr_program ~repl_mode:false + |> ignore; + with + InterpLib.Error.Fatal_error -> () + +(** Type-check given file and send the diagnostics to the client *) +let process_program state uri = + let real_path = Uri.path uri in + let temp_path = State.get_document_path uri state in + let diagnostics = ref [] in + + InterpLib.Error.set_report_function (report temp_path real_path diagnostics); + set_module_dirs real_path; + type_check temp_path; + + let params = + make_publish_diagnostics_params + ~uri ~diagnostics:(List.rev !diagnostics) () in + JsonRpc.send_notification state (PublishDiagnostics params) + +let handle_notification state notification = + match notification with + | Initialized -> + state + | Exit -> + exit 0 + | DidOpen { text_document = { uri; text; _ } } -> + let state = State.open_document uri text state in + process_program state uri; + state + | DidChange { text_document = { uri; _ }; content_changes } -> + let apply_content_change state change = + State.update_document uri change.text state in + let state = + List.fold_left apply_content_change state content_changes in + process_program state uri; + state + | DidClose { text_document = { uri } } -> + let state = State.close_document uri state in + state + +let handle_request state (request : request) = + match request with + | Initialize -> + let result : server_result = Initialize initialize_params in + state, result |> ok + | Shutdown -> + let state = State.close_all_documents state in + let result : server_result = Shutdown in + state, result |> ok + +let _ = + let state = State.create ~in_channel:stdin ~out_channel:stdout in + JsonRpc.run state handle_request handle_notification