aboutsummaryrefslogtreecommitdiffstats
path: root/bin/lsp.ml
blob: f7529f295cd08330e186876966cdd8ebf6c68e9c (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
(* 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