(* The Sol language server: JSON-RPC over stdio, hand rolled on yojson. Diagnostics run the same admission the runtime runs, so a rule with no decrease witness or a fact violating its schema is an error in the editor with the runtime's own message. Completion, hover, and definition work from a parse-level symbol scan of the document text, so they keep working while the buffer is broken mid-edit. Parse errors carry positions; admission errors anchor at the top of the file until the loader learns locations. *) let send json = let body = Yojson.Safe.to_string json in Printf.printf "Content-Length: %d\r\n\r\n%s" (String.length body) body; flush stdout let read_message () = let rec headers length = match In_channel.input_line stdin with | None -> None | Some line -> ( let line = String.trim line in if line = "" then length else match String.index_opt line ':' with | Some i when String.lowercase_ascii (String.sub line 0 i) = "content-length" -> headers (int_of_string_opt (String.trim (String.sub line (i + 1) (String.length line - i - 1)))) | _ -> headers length) in match headers None with | None -> None | Some length -> ( match really_input_string stdin length with | body -> ( try Some (Yojson.Safe.from_string body) with _ -> None) | exception End_of_file -> None) let member name json = match json with | `Assoc fields -> Option.value ~default:`Null (List.assoc_opt name fields) | _ -> `Null let to_string_opt = function `String s -> Some s | _ -> None let to_int_opt = function `Int i -> Some i | _ -> None (* ------------------------------------------------------------------ *) (* Document store and symbol scan. *) (* ------------------------------------------------------------------ *) type doc = { lines : string array } let documents : (string, doc) Hashtbl.t = Hashtbl.create 8 let store uri text = Hashtbl.replace documents uri { lines = Array.of_list (String.split_on_char '\n' text) } let is_ident_char c = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c = '_' (* The identifier under the cursor; a leading # is part of it and stripped, so hovering a reference reaches its relation. *) let token_at doc ~line ~character = if line < 0 || line >= Array.length doc.lines then None else let text = doc.lines.(line) in let n = String.length text in let i = min character (max 0 (n - 1)) in if n = 0 then None else let i = if i < n && is_ident_char text.[i] then i else i - 1 in if i < 0 || not (is_ident_char text.[i]) then None else let start = ref i and stop = ref i in while !start > 0 && is_ident_char text.[!start - 1] do decr start done; while !stop < n - 1 && is_ident_char text.[!stop + 1] do incr stop done; Some (String.sub text !start (!stop - !start + 1)) (* Definition sites, scanned per line so they survive parse errors: a relation is defined by its Relation directive or by the rules whose head applies it. *) let find_after text needle = let tl = String.length text and nl = String.length needle in let rec go i = if i + nl > tl then None else if String.sub text i nl = needle then Some (i + nl) else go (i + 1) in go 0 let ident_at text i = let n = String.length text in let rec skip i = if i < n && text.[i] = ' ' then skip (i + 1) else i in let start = skip i in let stop = ref start in while !stop < n && is_ident_char text.[!stop] do incr stop done; if !stop > start then Some (String.sub text start (!stop - start), start) else None let definitions doc name = let hits = ref [] in Array.iteri (fun lineno text -> let head_of needle = match find_after text needle with | Some i -> ident_at text i | None -> None in let relation = head_of "Relation named:" in let rule_head = match find_after text "head:" with | Some i -> ( match find_after (String.sub text i (String.length text - i)) "[" with | Some j -> ( match ident_at text (i + j) with | Some (n, col) -> Some (n, col) | None -> None) | None -> None) | None -> None in List.iter (function | Some (n, col) when String.equal n name -> hits := (lineno, col) :: !hits | _ -> ()) [ relation; rule_head ]) doc.lines; List.rev !hits (* Names available for completion: declared relations and rule heads, every label spelled in the document, builtins, directives and combinators. Harvested from text, so a broken buffer still completes. *) let harvest doc = let relations = ref [] and labels = ref [] in Array.iter (fun text -> let n = String.length text in let i = ref 0 in while !i < n do if is_ident_char text.[!i] then begin let start = !i in while !i < n && is_ident_char text.[!i] do incr i done; let word = String.sub text start (!i - start) in if !i < n && text.[!i] = ':' then begin if word.[0] >= 'a' && word.[0] <= 'z' then labels := word :: !labels end else if (word.[0] >= 'A' && word.[0] <= 'Z') && start > 0 && (text.[start - 1] = '[' || text.[start - 1] = '#') then relations := word :: !relations end else if text.[!i] = '%' then i := n else incr i done) doc.lines; ( List.sort_uniq String.compare !relations |> List.filter (fun name -> not (Sol.Builtin.is_builtin name)), List.sort_uniq String.compare !labels ) (* ------------------------------------------------------------------ *) (* What hover says. *) (* ------------------------------------------------------------------ *) let builtin_hover = function | "Integer" -> Some "[Integer value: T]\nmembership in the integers; checks once ground, residuates open" | "Natural" -> Some "[Natural value: T]\nmembership in the naturals (an Integer at least 0)" | "Rational" -> Some "[Rational value: T]\nmembership in the rationals; every Integer is one, literally" | "String" -> Some "[String value: T]\nmembership in the strings" | "Equal" -> Some "[Equal left: T right: T]\nunification" | "Plus" -> Some "[Plus left: T right: T result: T]\naddition over the rationals; any two ground arguments determine the third" | "Times" -> Some "[Times left: T right: T result: T]\nmultiplication over the rationals; solves backward except through zero" | "LessThan" -> Some "[LessThan left: T right: T]\ndecides once both sides are ground; contributes interval bounds while open" | "LessThanEqual" -> Some "[LessThanEqual left: T right: T]\ndecides once both sides are ground; contributes interval bounds while open" | "GreaterThan" -> Some "[GreaterThan left: T right: T]\ndecides once both sides are ground; contributes interval bounds while open" | "GreaterEqual" -> Some "[GreaterEqual left: T right: T]\ndecides once both sides are ground; contributes interval bounds while open" | "Between" -> Some "[Between value: T lower: T upper: T]\nbounded generation: enumerates the finite integer range" | "True" -> Some "[True]\nthe proposition that holds" | "World" -> Some "[World]\nthe universe; true of every tuple, scopes denial: [World] :not [...]" | "ForAll" -> Some "[ForAll variable: V quantifier: [...] body: [...]]\nuniversal: every quantifier solution satisfies the body; infinite domains discharge symbolically or reject" | "Exists" -> Some "[Exists variable: V quantifier: [...] body: [...]]\nexistential: lowers to a conjunction with V renamed apart" | "Insert" -> Some "[Insert relation: #R tuple: {...} before: S after: S']\ntransition relation between states (stateful worlds only)" | _ -> None (* For a declared relation: its defining lines, verbatim. The honest hover is the source of the definition. *) let declared_hover doc name = match definitions doc name with | [] -> None | sites -> let lines = List.map (fun (lineno, _) -> String.trim doc.lines.(lineno)) sites in Some (String.concat "\n" (List.sort_uniq String.compare lines)) (* ------------------------------------------------------------------ *) (* Diagnostics. *) (* ------------------------------------------------------------------ *) let diagnostics text = match Sol.Front.parse text with | Error { Sol.Front.line; column; message } -> [ (max 0 (line - 1), max 0 column, message) ] | Ok statements -> ( match Sol.Program.load statements with | Error error -> [ (0, 0, Format.asprintf "%a" Sol.Program.pp_error error) ] | Ok _ -> []) let publish uri text = let items = List.map (fun (line, column, message) -> `Assoc [ ( "range", `Assoc [ ( "start", `Assoc [ ("line", `Int line); ("character", `Int column) ] ); ( "end", `Assoc [ ("line", `Int line); ("character", `Int (column + 1)) ] ); ] ); ("severity", `Int 1); ("source", `String "sol"); ("message", `String message); ]) (diagnostics text) in send (`Assoc [ ("jsonrpc", `String "2.0"); ("method", `String "textDocument/publishDiagnostics"); ("params", `Assoc [ ("uri", `String uri); ("diagnostics", `List items) ]); ]) let respond id result = send (`Assoc [ ("jsonrpc", `String "2.0"); ("id", id); ("result", result) ]) let check_notification params = let uri = to_string_opt (member "uri" (member "textDocument" params)) in let text = match to_string_opt (member "text" (member "textDocument" params)) with | Some text -> Some text | None -> ( match member "contentChanges" params with | `List changes -> ( match List.rev changes with | last :: _ -> to_string_opt (member "text" last) | [] -> None) | _ -> None) in match (uri, text) with | Some uri, Some text -> store uri text; publish uri text | _ -> () (* ------------------------------------------------------------------ *) (* Requests. *) (* ------------------------------------------------------------------ *) let position params = let uri = to_string_opt (member "uri" (member "textDocument" params)) and line = to_int_opt (member "line" (member "position" params)) and character = to_int_opt (member "character" (member "position" params)) in match (uri, line, character) with | Some uri, Some line, Some character -> ( match Hashtbl.find_opt documents uri with | Some doc -> Some (uri, doc, line, character) | None -> None) | _ -> None let handle_hover params = match position params with | None -> `Null | Some (_, doc, line, character) -> ( match token_at doc ~line ~character with | None -> `Null | Some token -> ( let text = match builtin_hover token with | Some text -> Some text | None -> declared_hover doc token in match text with | None -> `Null | Some value -> `Assoc [ ( "contents", `Assoc [ ("kind", `String "plaintext"); ("value", `String value) ] ); ])) let handle_definition uri params = match position params with | None -> `Null | Some (_, doc, line, character) -> ( match token_at doc ~line ~character with | None -> `Null | Some token -> let locations = List.map (fun (lineno, col) -> `Assoc [ ("uri", `String uri); ( "range", `Assoc [ ( "start", `Assoc [ ("line", `Int lineno); ("character", `Int col) ] ); ( "end", `Assoc [ ("line", `Int lineno); ( "character", `Int (col + String.length token) ); ] ); ] ); ]) (definitions doc token) in `List locations) let directive_items = [ "Relation"; "Rule"; "Query" ] let combinator_items = [ ":and"; ":or"; ":not" ] let form_items = [ "ForAll"; "Exists" ] let handle_completion params = match position params with | None -> `Null | Some (_, doc, _, _) -> let relations, labels = harvest doc in let item ?detail kind label = `Assoc (List.concat [ [ ("label", `String label); ("kind", `Int kind) ]; (match detail with | Some d -> [ ("detail", `String d) ] | None -> []); ]) in (* LSP completion kinds: 7 class, 5 field, 14 keyword, 3 function. The client filters by prefix. *) let items = List.concat [ List.map (item 7 ~detail:"relation") relations; List.map (fun name -> item 3 ~detail:"builtin" name) (Sol.Builtin.names @ form_items); List.map (fun l -> item 5 ~detail:"label" (l ^ ":")) labels; List.map (item 14 ~detail:"directive") directive_items; List.map (item 14 ~detail:"combinator") combinator_items; ] in `Assoc [ ("isIncomplete", `Bool false); ("items", `List items) ] (* ------------------------------------------------------------------ *) (* The loop. *) (* ------------------------------------------------------------------ *) let run () = set_binary_mode_in stdin true; set_binary_mode_out stdout true; let running = ref true in while !running do match read_message () with | None -> running := false | Some message -> ( let id = member "id" message in let params = member "params" message in let uri = to_string_opt (member "uri" (member "textDocument" params)) in match to_string_opt (member "method" message) with | Some "initialize" -> respond id (`Assoc [ ( "capabilities", `Assoc [ ("textDocumentSync", `Int 1); ("hoverProvider", `Bool true); ("definitionProvider", `Bool true); ( "completionProvider", `Assoc [ ( "triggerCharacters", `List [ `String "["; `String "#"; `String ":" ] ); ] ); ] ); ( "serverInfo", `Assoc [ ("name", `String "sol-lsp"); ("version", `String "0.2.0"); ] ); ]) | Some "shutdown" -> respond id `Null | Some "exit" -> running := false | Some ("textDocument/didOpen" | "textDocument/didChange") -> check_notification params | Some "textDocument/didClose" -> ( match uri with | Some uri -> Hashtbl.remove documents uri; send (`Assoc [ ("jsonrpc", `String "2.0"); ("method", `String "textDocument/publishDiagnostics"); ( "params", `Assoc [ ("uri", `String uri); ("diagnostics", `List []) ] ); ]) | None -> ()) | Some "textDocument/hover" -> respond id (handle_hover params) | Some "textDocument/definition" -> respond id (handle_definition (Option.value ~default:"" uri) params) | Some "textDocument/completion" -> respond id (handle_completion params) | Some _ when id <> `Null -> send (`Assoc [ ("jsonrpc", `String "2.0"); ("id", id); ( "error", `Assoc [ ("code", `Int (-32601)); ("message", `String "method not found"); ] ); ]) | _ -> ()) done