diff --git a/CHANGES.md b/CHANGES.md index 07af88272..947b7fc65 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,6 +2,13 @@ ## Features +- Link the odoc markup of documentation comments through + `textDocument/documentLink`: the target of a `{{:...}}` link and of a `@see` + tag is its url, and a `{!...}` cross-reference resolves, on + `documentLink/resolve`, to the definition it names. + (#2139, fixes #436, @N1ark) +- Resolve odoc cross-references in hovered documentation tooltips. + (#2139, fixes #436, @N1ark) - Report Dune RPC, build progress, and Merlin configuration process activity through LSP trace notifications. (#1899, @rgrinberg) - Add a code action to open the closest Dune file for the current document. @@ -10,6 +17,9 @@ ## Fixes +- Stop percent-encoding sub-delimiters such as `=`, `&` and `,` in the query + and the fragment of a URI. RFC 3986 permits them there, and encoding them + changed the value a client read back out. (#2139, @N1ark) - Return document symbol kinds the client supports, falling back to `Constructor` and `Class` when it does not advertise the newer kinds. (#2122, fixes #2121, @dayangac) diff --git a/README.md b/README.md index 47fe2de5a..6efa1d0fc 100644 --- a/README.md +++ b/README.md @@ -182,6 +182,8 @@ The server supports the following LSP requests (inexhaustive list): - [x] `textDocument/codeLens` - [x] `textDocument/documentHighlight` - [x] `textDocument/documentSymbol` +- [x] `textDocument/documentLink` +- [x] `documentLink/resolve` - [x] `textDocument/references` - [ ] `textDocument/documentColor` - [ ] `textDocument/colorPresentation` diff --git a/lsp/src/uri0.ml b/lsp/src/uri0.ml index e69f8d0ca..80f7ff228 100644 --- a/lsp/src/uri0.ml +++ b/lsp/src/uri0.ml @@ -58,22 +58,22 @@ let to_path { path; authority; scheme; _ } = let of_string = Uri_lexer.of_string -let safe_chars = +let char_set chars = let a = Array.make 256 false in - let always_safe = - "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_.-~" - in - for i = 0 to String.length always_safe - 1 do - let c = Char.code always_safe.[i] in - a.(c) <- true - done; + String.iter chars ~f:(fun c -> a.(Char.code c) <- true); a ;; +(* RFC 3986 unreserved characters. *) +let unreserved = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_.-~" +let safe_chars = char_set unreserved + +(* A query or a fragment may hold extra sub-delimiters unencoded. *) +let query_safe_chars = char_set (unreserved ^ "!$&'()*+,;=:@/?") let slash_code = 47 (* https://github.com/mirage/ocaml-uri/blob/master/lib/uri.ml#L284 *) -let encode ?(allow_slash = false) s = +let encode ?(allow_slash = false) ?(safe_chars = safe_chars) s = let len = String.length s in let buf = Buffer.create len in let rec scan start cur = @@ -132,12 +132,12 @@ let to_string { scheme; authority; path; query; fragment } = | None -> () | Some q -> Buffer.add_char buff '?'; - Buffer.add_string buff (encode q)); + Buffer.add_string buff (encode ~safe_chars:query_safe_chars q)); (match fragment with | None -> () | Some f -> Buffer.add_char buff '#'; - Buffer.add_string buff (encode f)); + Buffer.add_string buff (encode ~safe_chars:query_safe_chars f)); Buffer.contents buff ;; diff --git a/lsp/test/uri_tests.ml b/lsp/test/uri_tests.ml index bffa16f73..c5ae26b28 100644 --- a/lsp/test/uri_tests.ml +++ b/lsp/test/uri_tests.ml @@ -130,6 +130,22 @@ let%expect_test "JSON URI serialization normalizes wire spelling" = |}] ;; +let%expect_test "sub-delimiters in a query and in a fragment" = + let test source = + Printf.printf "%s -> %s\n" source (Uri.of_string source |> Uri.to_string) + in + test "https://ocaml.org/search?q=a+b&page=1"; + test "file:///foo.ml#L3,4"; + (* [#] would be read back as the start of the fragment. *) + test "https://ocaml.org/?q=%23tag"; + [%expect + {| + https://ocaml.org/search?q=a+b&page=1 -> https://ocaml.org/search?q=a+b&page=1 + file:///foo.ml#L3,4 -> file:///foo.ml#L3,4 + https://ocaml.org/?q=%23tag -> https://ocaml.org/?q=%23tag + |}] +;; + let%expect_test "an unescaped Unicode URI query is preserved" = let uri = Uri.of_string "file:///foo.ml?search=😀&limit=1" in Printf.printf @@ -139,7 +155,7 @@ let%expect_test "an unescaped Unicode URI query is preserved" = [%expect {| query: search=😀&limit=1 - serialized: file:///foo.ml?search%3D%F0%9F%98%80%26limit%3D1 + serialized: file:///foo.ml?search=%F0%9F%98%80&limit=1 |}] ;; diff --git a/ocaml-lsp-server/src/doc_to_md.ml b/ocaml-lsp-server/src/doc_to_md.ml index 426b77f36..cbadaaf49 100644 --- a/ocaml-lsp-server/src/doc_to_md.ml +++ b/ocaml-lsp-server/src/doc_to_md.ml @@ -38,6 +38,7 @@ let style_inline ~meta (style : Odoc_parser.Ast.style) inline = ;; let rec inline_element_to_inline + ~resolve (inline : Odoc_parser.Ast.inline_element Odoc_parser.Loc.with_location) : Inline.t = @@ -60,26 +61,37 @@ let rec inline_element_to_inline let meta = loc_to_meta location in Inline.Text (text, meta) | { value = `Styled (`Superscript, inlines); location } -> - let text = inline_element_list_to_inlines inlines in + let text = inline_element_list_to_inlines ~resolve inlines in let meta = loc_to_meta location in Inline.Inlines ([ Inline.Text ("^{", meta); text; Inline.Text ("}", meta) ], meta) | { value = `Styled (`Subscript, inlines); location } -> - let text = inline_element_list_to_inlines inlines in + let text = inline_element_list_to_inlines ~resolve inlines in let meta = loc_to_meta location in Inline.Inlines ([ Inline.Text ("_{", meta); text; Inline.Text ("}", meta) ], meta) | { value = `Styled (style, inlines); location } -> - let text = inline_element_list_to_inlines inlines in + let text = inline_element_list_to_inlines ~resolve inlines in let meta = loc_to_meta location in style_inline ~meta style text | { value = `Reference (kind, ref, inlines); location } -> - (* TODO: add support for references *) let meta = loc_to_meta location in - (match kind with - | `Simple -> Inline.Code_span (Inline.Code_span.of_string ref.value, meta) - | `With_text -> inline_element_list_to_inlines inlines) + (* The kind qualifying a reference selects what it points at, it is not + part of what the reader should see. *) + let text = + match kind with + | `Simple -> + Inline.Code_span (Inline.Code_span.of_string (Odoc_reference.path ref.value), meta) + | `With_text -> inline_element_list_to_inlines ~resolve inlines + in + (match resolve ref.value with + | None -> text + | Some target -> + let definition = + `Inline (Link_definition.make ~dest:(target, Meta.none) (), Meta.none) + in + Inline.Link (Inline.Link.make text definition, meta)) | { value = `Link (link, inlines); location } -> let link = - let text = inline_element_list_to_inlines inlines in + let text = inline_element_list_to_inlines ~resolve inlines in let ref = `Inline (Link_definition.make ~dest:(link, Meta.none) (), Meta.none) in Inline.Link.make text ref in @@ -90,19 +102,20 @@ let rec inline_element_to_inline Inline.Ext_math_span (Inline.Math_span.make ~display:false (Block_line.tight_list_of_string text), meta) -and inline_element_list_to_inlines inlines = - let inlines = List.map ~f:inline_element_to_inline inlines in +and inline_element_list_to_inlines ~resolve inlines = + let inlines = List.map ~f:(inline_element_to_inline ~resolve) inlines in Inline.Inlines (inlines, Meta.none) ;; let rec nestable_block_element_to_block + ~resolve (nestable : Odoc_parser.Ast.nestable_block_element Odoc_parser.Loc.with_location) = match nestable with | { value = `Paragraph text; location } -> let paragraph = - let inline = inline_element_list_to_inlines text in + let inline = inline_element_list_to_inlines ~resolve text in Block.Paragraph.make inline in let meta = loc_to_meta location in @@ -123,7 +136,7 @@ let rec nestable_block_element_to_block [ (`Sep alignment, Meta.none), "" ] in let cell ((c, _) : Odoc_parser.Ast.nestable_block_element Odoc_parser.Ast.cell) = - let c = nestable_block_element_list_to_inlines c in + let c = nestable_block_element_list_to_inlines ~resolve c in c, (" ", " ") (* Initial and trailing blanks *) in @@ -144,7 +157,7 @@ let rec nestable_block_element_to_block let l = let list_items = List.map xs ~f:(fun n -> - let block = nestable_block_element_list_to_block n in + let block = nestable_block_element_list_to_block ~resolve n in Block.List_item.make ~after_marker:1 block, Meta.none) in let tight = @@ -208,7 +221,7 @@ let rec nestable_block_element_to_block let output_block = match output with | None -> [] - | Some output -> [ nestable_block_element_list_to_block output ] + | Some output -> [ nestable_block_element_list_to_block ~resolve output ] in Block.Blocks (main_block :: output_block, meta) | { value = `Verbatim code; location } -> @@ -228,14 +241,16 @@ let rec nestable_block_element_to_block Block.Ext_math_block (code_block, meta) and nestable_block_element_to_inlines + ~resolve (nestable : Odoc_parser.Ast.nestable_block_element Odoc_parser.Loc.with_location) = match nestable with - | { value = `Paragraph text; location = _ } -> inline_element_list_to_inlines text + | { value = `Paragraph text; location = _ } -> + inline_element_list_to_inlines ~resolve text | { value = `Table ((grid, _), _); location } -> let meta = loc_to_meta location in let cell ((c, _) : Odoc_parser.Ast.nestable_block_element Odoc_parser.Ast.cell) = - nestable_block_element_list_to_inlines c + nestable_block_element_list_to_inlines ~resolve c in let row (row : Odoc_parser.Ast.nestable_block_element Odoc_parser.Ast.row) = let sep = Inline.Text (" | ", Meta.none) in @@ -246,7 +261,7 @@ and nestable_block_element_to_inlines | { value = `List (_, _, xs); location } -> let meta = loc_to_meta location in let items = - let item i = nestable_block_element_list_to_inlines i in + let item i = nestable_block_element_list_to_inlines ~resolve i in let sep = Inline.Text (" - ", Meta.none) in List.concat_map ~f:(fun i -> [ sep; item i ]) xs in @@ -279,12 +294,12 @@ and nestable_block_element_to_inlines let code_span = Inline.Math_span.make ~display:true [ "", (code, Meta.none) ] in Inline.Ext_math_span (code_span, meta) -and nestable_block_element_list_to_inlines l = - let inlines = List.map ~f:nestable_block_element_to_inlines l in +and nestable_block_element_list_to_inlines ~resolve l = + let inlines = List.map ~f:(nestable_block_element_to_inlines ~resolve) l in Inline.Inlines (inlines, Meta.none) -and nestable_block_element_list_to_block nestables = - let blocks = List.map ~f:nestable_block_element_to_block nestables in +and nestable_block_element_list_to_block ~resolve nestables = + let blocks = List.map ~f:(nestable_block_element_to_block ~resolve) nestables in Block.Blocks (blocks, Meta.none) ;; @@ -307,7 +322,7 @@ let inline_link_of_string ~text uri = Inline.Link (Inline.Link.make (Inline.Text (text, Meta.none)) ref, Meta.none) ;; -let tag_to_block ~meta (tag : Odoc_parser.Ast.tag) = +let tag_to_block ~resolve ~meta (tag : Odoc_parser.Ast.tag) = let format_tag_empty tag = Block.Paragraph (Block.Paragraph.make (strong_and_emphasis tag), Meta.none) in @@ -339,35 +354,35 @@ let tag_to_block ~meta (tag : Odoc_parser.Ast.tag) = let s = Inline.Text (s, Meta.none) in format_tag_string "@author" s | `Deprecated text -> - let block = nestable_block_element_list_to_block text in + let block = nestable_block_element_list_to_block ~resolve text in format_tag_block "@deprecated" block | `Param (id, []) -> let id = Inline.Text (id, Meta.none) in format_tag_string "@param" id | `Param (id, text) -> - let block = nestable_block_element_list_to_block text in + let block = nestable_block_element_list_to_block ~resolve text in let id = inline_code_span_of_string id in format_tag_string_with_block "@param" id block | `Raise (exc, text) -> - let block = nestable_block_element_list_to_block text in + let block = nestable_block_element_list_to_block ~resolve text in let exc = inline_code_span_of_string exc in format_tag_string_with_block "@raise" exc block | `Return text -> - let block = nestable_block_element_list_to_block text in + let block = nestable_block_element_list_to_block ~resolve text in format_tag_block "@return" block | `See (`Url, uri, text) -> - let block = nestable_block_element_list_to_block text in + let block = nestable_block_element_list_to_block ~resolve text in let uri = inline_link_of_string ~text:"link" uri in format_tag_string_with_block "@see" uri block | `See ((`File | `Document), uri, text) -> - let block = nestable_block_element_list_to_block text in + let block = nestable_block_element_list_to_block ~resolve text in let uri = inline_code_span_of_string uri in format_tag_string_with_block "@see" uri block | `Since version -> let version = inline_code_span_of_string version in format_tag_string "@since" version | `Before (version, text) -> - let block = nestable_block_element_list_to_block text in + let block = nestable_block_element_list_to_block ~resolve text in let version = inline_code_span_of_string version in format_tag_string_with_block "@before" version block | `Version version -> @@ -383,19 +398,20 @@ let tag_to_block ~meta (tag : Odoc_parser.Ast.tag) = ;; let rec block_element_to_block + ~resolve (block_element : Odoc_parser.Ast.block_element Odoc_parser.Loc.with_location) = match block_element with | { value = `Heading (level, _, content); location } -> let heading = - let text = inline_element_list_to_inlines content in + let text = inline_element_list_to_inlines ~resolve content in Block.Heading.make ~level:(level + 1) text in let meta = loc_to_meta location in Block.Heading (heading, meta) | { value = `Tag t; location } -> let meta = loc_to_meta location in - tag_to_block ~meta t + tag_to_block ~resolve ~meta t | { value = ( `Paragraph _ | `List _ @@ -405,25 +421,27 @@ let rec block_element_to_block | `Table _ | `Math_block _ ) ; location = _ - } as nestable -> nestable_block_element_to_block nestable + } as nestable -> nestable_block_element_to_block ~resolve nestable -and block_element_list_to_block l = +and block_element_list_to_block ~resolve l = let rec aux acc rest = match rest with | [] -> List.rev acc - | el :: [] -> List.rev (block_element_to_block el :: acc) + | el :: [] -> List.rev (block_element_to_block ~resolve el :: acc) | el :: rest -> - aux (Block.Blank_line ("", Meta.none) :: block_element_to_block el :: acc) rest + aux + (Block.Blank_line ("", Meta.none) :: block_element_to_block ~resolve el :: acc) + rest in let blocks = aux [] l in Block.Blocks (blocks, Meta.none) ;; -let translate doc : t = +let translate ?(resolve = fun _ -> None) doc : t = Markdown (Odoc_parser.parse_comment ~location:Lexing.dummy_pos ~text:doc |> Odoc_parser.ast - |> block_element_list_to_block + |> block_element_list_to_block ~resolve |> Doc.make |> Cmarkit_commonmark.of_doc) ;; diff --git a/ocaml-lsp-server/src/doc_to_md.mli b/ocaml-lsp-server/src/doc_to_md.mli index 6dbfb9b2f..b07bb2b68 100644 --- a/ocaml-lsp-server/src/doc_to_md.mli +++ b/ocaml-lsp-server/src/doc_to_md.mli @@ -2,4 +2,7 @@ type t = | Raw of string | Markdown of string -val translate : string -> t +(** [translate ?resolve doc] renders the odoc markup [doc] as markdown. + [resolve] says where a cross-reference points, as a URI; references it + answers [None] for are rendered as their path alone, without a link. *) +val translate : ?resolve:(string -> string option) -> string -> t diff --git a/ocaml-lsp-server/src/document_link.ml b/ocaml-lsp-server/src/document_link.ml new file mode 100644 index 000000000..e056bb70b --- /dev/null +++ b/ocaml-lsp-server/src/document_link.ml @@ -0,0 +1,224 @@ +open Import +open Fiber.O +module Env_lookup = Merlin_analysis.Env_lookup +module Locate = Merlin_analysis.Locate + +(** A documentation comment, paired with what is needed to turn the spans + odoc-parser reports back into source positions. *) +type comment = + { parsed : Odoc_parser.t + ; text : string (** the comment's contents, without its delimiters *) + ; offset : int (** [pos_cnum] of the first character of [text] *) + } + +(* Merlin hands us the contents of a comment without its delimiters, + so a documentation comment arrives with a single leading asterisk. *) +let parse (text, (loc : Loc.t)) = + let open Option.O in + let* text = String.chop_prefix text ~prefix:"*" in + if String.is_prefix text ~prefix:"*" + then None (* the comment is "(*** ... *)", not documentation *) + else ( + let start = loc.loc_start in + (* [parse_comment] wants the position of the character right after the + three-character opening delimiter of a documentation comment. *) + let offset = start.pos_cnum + 3 in + let location = { start with Lexing.pos_cnum = offset } in + Some { parsed = Odoc_parser.parse_comment ~location ~text; text; offset }) +;; + +(** What a link in a documentation comment points at. A cross-reference has to + later be resolved against the environment at the place it appears. *) +type target = + | Url of string + | Reference of string + +module Resolve = struct + type t = + { uri : Uri.t + ; position : Position.t + ; reference : string + } + + let yojson_of_t { uri; position; reference } = + `Assoc + [ "uri", Uri.yojson_of_t uri + ; "position", Position.yojson_of_t position + ; "reference", `String reference + ] + ;; + + let t_of_yojson json = + match json with + | `Assoc fields -> + { uri = Json.field_exn fields "uri" Uri.t_of_yojson + ; position = Json.field_exn fields "position" Position.t_of_yojson + ; reference = Json.field_exn fields "reference" Json.Conv.string_of_yojson + } + | json -> Json.error "invalid document link data" json + ;; +end + +let range t (span : Odoc_parser.Loc.span) = + let open Option.O in + let position point = + Position.of_lexical_position (Odoc_parser.position_of_point t.parsed point) + in + let* start = position span.start in + let+ end_ = position span.end_ in + { Range.start; end_ } +;; + +let index_of_point t point = + (Odoc_parser.position_of_point t.parsed point).pos_cnum - t.offset +;; + +(** Walk [text] from [from] to [to_], keeping track of the point [from] + corresponds to. *) +let advance t ~from ~to_ point = + let rec loop i (point : Odoc_parser.Loc.point) = + if i >= to_ + then point + else + loop + (i + 1) + (if Char.equal t.text.[i] '\n' + then { Odoc_parser.Loc.line = point.line + 1; column = 0 } + else { point with column = point.column + 1 }) + in + loop from point +;; + +(** [`See] tags carry no location for their target, and the span of the tag + itself covers the description that follows it, so locate the delimited + target within the comment's text by hand. *) +let see_target_span t ~(tag : Odoc_parser.Loc.span) ~target = + let open Option.O in + let start_index = index_of_point t tag.start in + let* () = Option.some_if (start_index >= 0) () in + let* opening = String.index_from t.text start_index '<' in + let target_start = opening + 1 in + let target_stop = target_start + String.length target in + let* () = + Option.some_if + (target_stop < String.length t.text && Char.equal t.text.[target_stop] '>') + () + in + let start = advance t ~from:start_index ~to_:target_start tag.start in + let end_ = advance t ~from:target_start ~to_:target_stop start in + Some { tag with Odoc_parser.Loc.start; end_ } +;; + +let rec inline_elements + t + acc + (elements : Odoc_parser.Ast.inline_element Odoc_parser.Loc.with_location list) + = + List.fold elements ~init:acc ~f:(fun acc { Odoc_parser.Loc.value; location } -> + match value with + | `Link (url, content) -> inline_elements t ((location, Url url) :: acc) content + | `Reference (_, reference, content) -> + inline_elements t ((location, Reference reference.value) :: acc) content + | `Styled (_, content) -> inline_elements t acc content + | `Space _ | `Word _ | `Code_span _ | `Raw_markup _ | `Math_span _ -> acc) +;; + +let rec nestable_block_elements + t + acc + (elements : + Odoc_parser.Ast.nestable_block_element Odoc_parser.Loc.with_location list) + = + List.fold elements ~init:acc ~f:(fun acc { Odoc_parser.Loc.value; location = _ } -> + match value with + | `Paragraph inlines -> inline_elements t acc inlines + | `List (_, _, items) -> List.fold items ~init:acc ~f:(nestable_block_elements t) + | `Table ((grid, _), _) -> + List.fold grid ~init:acc ~f:(fun acc row -> + List.fold row ~init:acc ~f:(fun acc (cell, _) -> + nestable_block_elements t acc cell)) + | `Code_block { output = Some output; _ } -> nestable_block_elements t acc output + | `Code_block _ | `Verbatim _ | `Modules _ | `Math_block _ -> acc) +;; + +let tag t acc ~location (tag : Odoc_parser.Ast.tag) = + match tag with + | `See (`Url, target, content) -> + let acc = + match see_target_span t ~tag:location ~target with + | None -> acc + | Some span -> (span, Url target) :: acc + in + nestable_block_elements t acc content + | `See (_, _, content) + | `Deprecated content + | `Return content + | `Param (_, content) + | `Raise (_, content) + | `Before (_, content) -> nestable_block_elements t acc content + | `Author _ | `Since _ | `Version _ | `Canonical _ | `Inline | `Open | `Closed | `Hidden + -> acc +;; + +let targets t = + List.fold (Odoc_parser.ast t.parsed) ~init:[] ~f:(fun acc element -> + match element.Odoc_parser.Loc.value with + | `Heading (_, _, inlines) -> inline_elements t acc inlines + | `Tag v -> tag t acc ~location:element.location v + | #Odoc_parser.Ast.nestable_block_element as value -> + nestable_block_elements t acc [ { element with value } ]) +;; + +let of_comment ~uri comment = + match parse comment with + | None -> [] + | Some t -> + targets t + |> List.rev_filter_map ~f:(fun (span, target) -> + let open Option.O in + let+ range = range t span in + match target with + | Url url -> DocumentLink.create ~range ~target:(Uri.of_string url) () + | Reference reference -> + let data = + Resolve.yojson_of_t { Resolve.uri; position = range.start; reference } + in + DocumentLink.create ~range ~data ()) +;; + +let run (state : State.t) uri = + let* () = Fiber.return () in + let doc = Document_store.get state.store uri in + match Document.kind doc with + | `Other -> Fiber.return None + | `Merlin merlin -> + let+ comments = + Document.Merlin.with_pipeline_exn + ~name:"document-link" + merlin + Mpipeline.reader_comments + in + Some (List.concat_map comments ~f:(of_comment ~uri)) +;; + +let resolve (state : State.t) (link : DocumentLink.t) = + let* () = Fiber.return () in + match Option.map link.data ~f:Resolve.t_of_yojson with + | None -> Fiber.return link + | Some { uri; position; reference } -> + (match Document_store.get_opt state.store uri with + | None -> Fiber.return link + | Some doc -> + (match Document.kind doc with + | `Other -> Fiber.return link + | `Merlin merlin -> + let+ target = + Document.Merlin.with_pipeline_exn + ~name:"document-link-resolve" + merlin + (fun pipeline -> Odoc_reference.resolve pipeline ~uri ~position reference) + in + (match target with + | None -> link + | Some target -> { link with target = Some target }))) +;; diff --git a/ocaml-lsp-server/src/document_link.mli b/ocaml-lsp-server/src/document_link.mli new file mode 100644 index 000000000..d7bfa42ca --- /dev/null +++ b/ocaml-lsp-server/src/document_link.mli @@ -0,0 +1,9 @@ +open Import + +(** Links found in the odoc markup of a document's documentation comments. *) +val run : State.t -> Uri.t -> DocumentLink.t list option Fiber.t + +(** [resolve state link] points [link] at the definition of the cross-reference + it was built from, or returns it unchanged when the reference cannot be + placed. *) +val resolve : State.t -> DocumentLink.t -> DocumentLink.t Fiber.t diff --git a/ocaml-lsp-server/src/hover_req.ml b/ocaml-lsp-server/src/hover_req.ml index 723b6f000..50976ad2b 100644 --- a/ocaml-lsp-server/src/hover_req.ml +++ b/ocaml-lsp-server/src/hover_req.ml @@ -295,6 +295,7 @@ let format_type_enclosing ~markdown ~typ ~doc + ~resolve ~(syntax_doc : Query_protocol.syntax_doc_result option) = (* TODO for vscode, we should just use the language id. But that will not work @@ -315,7 +316,7 @@ let format_type_enclosing let type_info = Some (format_as_code_block ~highlighter:markdown_name [ typ ]) in let doc = Option.map doc ~f:(fun doc -> - match Doc_to_md.translate doc with + match Doc_to_md.translate ~resolve doc with | Raw d -> d | Markdown d -> d) in @@ -400,12 +401,29 @@ let type_enclosing_hover in typ in + let markdown = + Capabilities.supports_markdown + (Capabilities.hover_content_format (State.client_capabilities state)) + in + (* Resolving a cross-reference needs merlin, so settle every one the + documentation mentions while we hold a pipeline. *) + let* resolve = + match markdown, documentation with + | false, _ | _, None -> Fiber.return (fun _ -> None) + | true, Some documentation -> + let+ resolved = + Document.Merlin.with_pipeline_exn + ~name:"hover-doc-references" + merlin + (fun pipeline -> + Odoc_reference.resolve_all pipeline ~uri ~position documentation) + in + fun reference -> + List.Assoc.find resolved reference ~equal:String.equal + |> Option.map ~f:Uri.to_string + in let contents = - let markdown = - Capabilities.supports_markdown - (Capabilities.hover_content_format (State.client_capabilities state)) - in - format_type_enclosing ~syntax ~markdown ~typ ~doc:documentation ~syntax_doc + format_type_enclosing ~syntax ~markdown ~typ ~doc:documentation ~resolve ~syntax_doc in let range = Range.of_loc loc in let hover = Hover.create ~contents ~range () in diff --git a/ocaml-lsp-server/src/import.ml b/ocaml-lsp-server/src/import.ml index 6cc48d9a6..2bc5ffcba 100644 --- a/ocaml-lsp-server/src/import.ml +++ b/ocaml-lsp-server/src/import.ml @@ -216,6 +216,8 @@ include struct module DocumentHighlight = DocumentHighlight module DocumentHighlightKind = DocumentHighlightKind module DocumentHighlightParams = DocumentHighlightParams + module DocumentLink = DocumentLink + module DocumentLinkOptions = DocumentLinkOptions module DocumentSymbol = DocumentSymbol module DocumentUri = DocumentUri module ExecuteCommandOptions = ExecuteCommandOptions diff --git a/ocaml-lsp-server/src/ocaml_lsp_server.ml b/ocaml-lsp-server/src/ocaml_lsp_server.ml index 601f24bfa..2c054dfd8 100644 --- a/ocaml-lsp-server/src/ocaml_lsp_server.ml +++ b/ocaml-lsp-server/src/ocaml_lsp_server.ml @@ -60,6 +60,7 @@ let initialize_info (client_capabilities : ClientCapabilities.t) : InitializeRes ()) in let codeLensProvider = CodeLensOptions.create ~resolveProvider:false () in + let documentLinkProvider = DocumentLinkOptions.create ~resolveProvider:true () in let completionProvider = CompletionOptions.create ~triggerCharacters:[ "."; "#" ] ~resolveProvider:true () in @@ -171,6 +172,7 @@ let initialize_info (client_capabilities : ClientCapabilities.t) : InitializeRes ~documentRangeFormattingProvider:(`Bool true) ~selectionRangeProvider:(`Bool true) ~documentSymbolProvider:(`Bool true) + ~documentLinkProvider ~workspaceSymbolProvider:(`Bool true) ~foldingRangeProvider:(`Bool true) ?semanticTokensProvider @@ -718,8 +720,10 @@ let on_request | TextDocumentRename req -> later Rename.rename req | TextDocumentFoldingRange req -> later Folding_range.compute req | SignatureHelp req -> later Signature_help.run req - | TextDocumentLinkResolve l -> now l - | TextDocumentLink _ -> now None + | TextDocumentLinkResolve link -> + later (fun state () -> Document_link.resolve state link) () + | TextDocumentLink { textDocument = { uri }; _ } -> + later (fun state () -> Document_link.run state uri) () | WillSaveWaitUntilTextDocument _ -> now None | TextDocumentFormatting { textDocument = { uri }; options = _; _ } -> later diff --git a/ocaml-lsp-server/src/odoc_reference.ml b/ocaml-lsp-server/src/odoc_reference.ml new file mode 100644 index 000000000..e1c08ef30 --- /dev/null +++ b/ocaml-lsp-server/src/odoc_reference.ml @@ -0,0 +1,151 @@ +open Import +module Env_lookup = Merlin_analysis.Env_lookup +module Locate = Merlin_analysis.Locate + +(** Split an odoc reference into its kind (if any) and its path, e.g. + [value:foo] returns [(Some "value", "foo")], [bar] returns [(None, "bar")]. *) +let split reference = + let split separator component = + match String.rsplit2 component ~on:separator with + | Some (kind, name) -> Some kind, name + | None -> None, component + in + let kind, path = split ':' reference in + let components = String.split path ~on:'.' in + let kind, components = + match List.rev components with + | [] -> kind, components + | last :: rest -> + let of_component, last = split '-' last in + Option.first_some kind of_component, List.rev (last :: rest) + in + kind, String.concat components ~sep:"." +;; + +let path reference = snd (split reference) + +(** Say which namespaces to search: the one its kind names, or all of them. *) +let namespaces_of_kind kind : Env_lookup.Namespace.inferred_basic list = + match kind with + | Some "type" -> [ `Type ] + | Some ("val" | "value") -> [ `Vals ] + | Some "module" -> [ `Mod ] + | Some ("modtype" | "module-type") -> [ `Modtype ] + | Some "constructor" -> [ `Constr ] + | Some "field" -> [ `Labels ] + (* An unqualified reference, or one whose kind has no namespace of its own + such as [exception] or [method]. *) + | Some _ | None -> [ `Type; `Vals; `Mod; `Modtype; `Constr; `Labels ] +;; + +(** Find the appropriate environment in which to resolve references. *) +let signature_env (node : Browse_raw.node) = + match node with + | Structure structure -> Some structure.str_final_env + | Signature signature -> Some signature.sig_final_env + | Module_expr { mod_desc = Tmod_structure structure; _ } -> Some structure.str_final_env + | Module_type { mty_desc = Tmty_signature signature; _ } -> Some signature.sig_final_env + | _ -> None +;; + +let enclosing_signature_env local_defs pos = + Mbrowse.enclosing pos [ Mbrowse.of_typedtree local_defs ] + |> List.find_map ~f:(fun (_, node) -> signature_env node) +;; + +(** [DocumentLink.target] and a markdown link are both plain URIs, so a position + within the file goes in the fragment, as [#L,]. *) +let target ~uri file (position : Lexing.position) = + let open Option.O in + let+ { Position.line; character } = Position.of_lexical_position position in + let uri = Option.value_map file ~default:uri ~f:Uri.of_path in + Uri.of_string (sprintf "%s#L%d,%d" (Uri.to_string uri) (line + 1) (character + 1)) +;; + +let resolve pipeline ~uri ~position reference = + let open Option.O in + let pos = Mpipeline.get_lexing_pos pipeline (Position.logical position) in + let local_defs = Mtyper.get_typedtree (Mpipeline.typer_result pipeline) in + let* env = enclosing_signature_env local_defs pos in + let kind, path = split reference in + let config = + { Locate.mconfig = Mpipeline.final_config pipeline + ; ml_or_mli = `Smart + ; traverse_aliases = true + } + in + let namespaces = namespaces_of_kind kind in + match Locate.from_string ~config ~env ~local_defs ~pos ~namespaces path with + | `Found { Locate.file; location; _ } -> target ~uri (Some file) location.loc_start + (* Merlin cannot always place a reference: it may name something from a unit + that has not been built, a builtin, or nothing at all. *) + | `At_origin + | `Builtin _ + | `File_not_found _ + | `Missing_labels_namespace + | `Not_found _ + | `Not_in_env _ -> None +;; + +(** Collect every reference a comment mentions, so that a caller holding a + pipeline can settle them all at once. *) +let rec of_inline_elements + acc + (elements : Odoc_parser.Ast.inline_element Odoc_parser.Loc.with_location list) + = + List.fold elements ~init:acc ~f:(fun acc { Odoc_parser.Loc.value; location = _ } -> + match value with + | `Reference (_, reference, content) -> + of_inline_elements (reference.Odoc_parser.Loc.value :: acc) content + | `Styled (_, content) | `Link (_, content) -> of_inline_elements acc content + | `Space _ | `Word _ | `Code_span _ | `Raw_markup _ | `Math_span _ -> acc) +;; + +let rec of_nestable_block_elements + acc + (elements : + Odoc_parser.Ast.nestable_block_element Odoc_parser.Loc.with_location list) + = + List.fold elements ~init:acc ~f:(fun acc { Odoc_parser.Loc.value; location = _ } -> + match value with + | `Paragraph inlines -> of_inline_elements acc inlines + | `List (_, _, items) -> List.fold items ~init:acc ~f:of_nestable_block_elements + | `Table ((grid, _), _) -> + List.fold grid ~init:acc ~f:(fun acc row -> + List.fold row ~init:acc ~f:(fun acc (cell, _) -> + of_nestable_block_elements acc cell)) + | `Code_block { output = Some output; _ } -> of_nestable_block_elements acc output + | `Code_block _ | `Verbatim _ | `Modules _ | `Math_block _ -> acc) +;; + +let of_tag acc (tag : Odoc_parser.Ast.tag) = + match tag with + | `Deprecated content + | `Return content + | `See (_, _, content) + | `Param (_, content) + | `Raise (_, content) + | `Before (_, content) -> of_nestable_block_elements acc content + | `Author _ | `Since _ | `Version _ | `Canonical _ | `Inline | `Open | `Closed | `Hidden + -> acc +;; + +let of_comment text = + Odoc_parser.parse_comment ~location:Lexing.dummy_pos ~text + |> Odoc_parser.ast + |> List.fold ~init:[] ~f:(fun acc element -> + match element.Odoc_parser.Loc.value with + | `Heading (_, _, inlines) -> of_inline_elements acc inlines + | `Tag v -> of_tag acc v + | #Odoc_parser.Ast.nestable_block_element as value -> + of_nestable_block_elements acc [ { element with Odoc_parser.Loc.value } ]) + |> List.dedup_and_sort ~compare:String.compare +;; + +let resolve_all pipeline ~uri ~position text = + of_comment text + |> List.filter_map ~f:(fun reference -> + let open Option.O in + let+ target = resolve pipeline ~uri ~position reference in + reference, target) +;; diff --git a/ocaml-lsp-server/src/odoc_reference.mli b/ocaml-lsp-server/src/odoc_reference.mli new file mode 100644 index 000000000..153eb8cd7 --- /dev/null +++ b/ocaml-lsp-server/src/odoc_reference.mli @@ -0,0 +1,27 @@ +open Import + +(** An odoc cross-reference, as written between [{!] and [}] in a documentation + comment, and the definition it names. *) + +(** [split reference] separates the kind qualifying [reference] from its path. + A reference may name the kind of item it points at, either as a leading + [kind:] or as a [kind-] prefix on its last component. *) +val split : string -> string option * string + +(** [path reference] is [reference] stripped of the kind qualifying it. *) +val path : string -> string + +(** [resolve pipeline ~uri ~position reference] is the definition [reference] + names, as a URI carrying the position in the file it points into. + [position] is where the reference is written; [uri] is the document it is + written in. [None] when merlin cannot place it. *) +val resolve : Mpipeline.t -> uri:Uri.t -> position:Position.t -> string -> Uri.t option + +(** [resolve_all pipeline ~uri ~position text] resolves every cross-reference + the odoc markup [text] mentions, dropping those that cannot be placed. *) +val resolve_all + : Mpipeline.t + -> uri:Uri.t + -> position:Position.t + -> string + -> (string * Uri.t) list diff --git a/ocaml-lsp-server/test/e2e-new/document_link.ml b/ocaml-lsp-server/test/e2e-new/document_link.ml new file mode 100644 index 000000000..b0707fa5f --- /dev/null +++ b/ocaml-lsp-server/test/e2e-new/document_link.ml @@ -0,0 +1,336 @@ +open Test.Import + +let document_link ?(uri = Helpers.uri) client = + let textDocument = TextDocumentIdentifier.create ~uri in + Client.request client (TextDocumentLink (DocumentLinkParams.create ~textDocument ())) +;; + +let print_link (link : DocumentLink.t) = + let target = + Option.map link.target ~f:(fun target -> + match DocumentUri.to_string target |> String.chop_prefix ~prefix:"file://" with + | None -> DocumentUri.to_string target + | Some path -> Filename.basename path) + in + DocumentLink.yojson_of_t + (DocumentLink.create + ~range:link.range + ?target:(Option.map target ~f:DocumentUri.of_string) + ?tooltip:link.tooltip + ()) +;; + +let print_links = Test.print_option_list ~none:"null" print_link + +let test source = + let req client = + let* response = document_link client in + print_links response; + Fiber.return () + in + Helpers.test source req +;; + +(* Cross-references carry no target until the client asks for one. *) +let test_resolved ?uri source = + let req client = + let* response = document_link ?uri client in + let* resolved = + Fiber.sequential_map (Option.value response ~default:[]) ~f:(fun link -> + Client.request client (TextDocumentLinkResolve link)) + in + print_links (Some resolved); + Fiber.return () + in + Helpers.test ?uri source req +;; + +let%expect_test "links the url of an odoc link" = + test + {|(** See {{:https://ocaml.org} the website}. *) +let x = 1|}; + [%expect + {| + [ + { + "range": { + "end": { "character": 42, "line": 0 }, + "start": { "character": 8, "line": 0 } + }, + "target": "https://ocaml.org/" + } + ] + |}] +;; + +let%expect_test "links the url of a @see tag" = + test + {|(** Does nothing. + + @see the manual *) +let x = 1|}; + [%expect + {| + [ + { + "range": { + "end": { "character": 34, "line": 2 }, + "start": { "character": 10, "line": 2 } + }, + "target": "https://ocaml.org/manual" + } + ] + |}] +;; + +let%expect_test "preserves the query and fragment of a url" = + test + {|(** See {{:https://ocaml.org/p?q=1#top} releases}. *) +let x = 1|}; + [%expect + {| + [ + { + "range": { + "end": { "character": 49, "line": 0 }, + "start": { "character": 8, "line": 0 } + }, + "target": "https://ocaml.org/p?q=1#top" + } + ] + |}] +;; + +let%expect_test "finds links nested in markup" = + test + {|(** {2 A {{:https://ocaml.org} heading}} + + - a {b bold {{:https://opam.ocaml.org} item}} *) +let x = 1|}; + [%expect + {| + [ + { + "range": { + "end": { "character": 39, "line": 0 }, + "start": { "character": 9, "line": 0 } + }, + "target": "https://ocaml.org/" + }, + { + "range": { + "end": { "character": 48, "line": 2 }, + "start": { "character": 16, "line": 2 } + }, + "target": "https://opam.ocaml.org/" + } + ] + |}] +;; + +let%expect_test "links odoc cross-references" = + test + {|type t = Foo + +(** Know if a value of type {!t} is {!Foo}. *) +let is_foo Foo = true|}; + [%expect + {| + [ + { + "range": { + "end": { "character": 32, "line": 2 }, + "start": { "character": 28, "line": 2 } + } + }, + { + "range": { + "end": { "character": 42, "line": 2 }, + "start": { "character": 36, "line": 2 } + } + } + ] + |}] +;; + +let%expect_test "resolves a cross-reference to an earlier definition" = + test_resolved + {|type t = Foo + +(** Know if a value of type {!t} is {!Foo}. *) +let is_foo Foo = true +|}; + [%expect + {| + [ + { + "range": { + "end": { "character": 32, "line": 2 }, + "start": { "character": 28, "line": 2 } + }, + "target": "file:///test.ml#L1,6" + }, + { + "range": { + "end": { "character": 42, "line": 2 }, + "start": { "character": 36, "line": 2 } + }, + "target": "file:///test.ml#L1,10" + } + ] + |}] +;; + +let%expect_test "resolves a cross-reference within a recursive group" = + test_resolved + {|(** Built on {!helper}. *) +let rec main () = helper () + +and helper () = () + +let other = 1 +|}; + [%expect + {| + [ + { + "range": { + "end": { "character": 22, "line": 0 }, + "start": { "character": 13, "line": 0 } + }, + "target": "file:///test.ml#L4,5" + } + ] + |}] +;; + +let%expect_test "resolves a cross-reference qualified by its kind" = + test_resolved + {|type t = Foo + +let is_foo Foo = true + +(** See {!type:t} and {!val:is_foo}. *) +let other = 1 +|}; + [%expect + {| + [ + { + "range": { + "end": { "character": 17, "line": 4 }, + "start": { "character": 8, "line": 4 } + }, + "target": "file:///test.ml#L1,6" + }, + { + "range": { + "end": { "character": 35, "line": 4 }, + "start": { "character": 22, "line": 4 } + }, + "target": "file:///test.ml#L3,5" + } + ] + |}] +;; + +(* The [t] below is [M.t], not the one at the end of the file: a reference + resolves against the signature enclosing it, as it does under odoc. *) +let%expect_test "resolves a reference shadowed by an outer name" = + test_resolved + {|module M = struct + (** this is {!t} *) + type t = A +end + +type t = B +|}; + [%expect + {| + [ + { + "range": { + "end": { "character": 18, "line": 1 }, + "start": { "character": 14, "line": 1 } + }, + "target": "file:///test.ml#L3,8" + } + ] + |}] +;; + +let%expect_test "resolves a forward cross-reference" = + test_resolved + {|let unrelated = 0 + +(** Applies {!f}. *) +let f x = x +|}; + [%expect + {| + [ + { + "range": { + "end": { "character": 16, "line": 2 }, + "start": { "character": 12, "line": 2 } + }, + "target": "file:///test.ml#L4,5" + } + ] + |}] +;; + +(* The odoc convention puts the comment after the item it documents. *) +let%expect_test "resolves a reference to the item just above it" = + test_resolved + ~uri:(DocumentUri.of_path "test.mli") + {|type t + +val helper : t -> t +(** Uses {!helper} and {!t}. *) +|}; + [%expect + {| + [ + { + "range": { + "end": { "character": 18, "line": 3 }, + "start": { "character": 9, "line": 3 } + }, + "target": "file:///test.mli#L3,5" + }, + { + "range": { + "end": { "character": 27, "line": 3 }, + "start": { "character": 23, "line": 3 } + }, + "target": "file:///test.mli#L1,6" + } + ] + |}] +;; + +let%expect_test "leaves an unknown cross-reference without a target" = + test_resolved + {|(** Refers to {!Nonexistent.thing}. *) +let x = 1 +|}; + [%expect + {| + [ + { + "range": { + "end": { "character": 34, "line": 0 }, + "start": { "character": 14, "line": 0 } + } + } + ] + |}] +;; + +let%expect_test "ignores ordinary comments" = + test + {|(* {{:https://ocaml.org} not a doc comment} *) +let x = 1|}; + [%expect {| [] |}] +;; diff --git a/ocaml-lsp-server/test/e2e-new/document_link_workspace.ml b/ocaml-lsp-server/test/e2e-new/document_link_workspace.ml new file mode 100644 index 000000000..a0bec4041 --- /dev/null +++ b/ocaml-lsp-server/test/e2e-new/document_link_workspace.ml @@ -0,0 +1,90 @@ +open Test.Import + +let print_link (link : DocumentLink.t) = + let target = + Option.map link.target ~f:(fun target -> + match DocumentUri.to_string target |> String.chop_prefix ~prefix:"file://" with + | None -> DocumentUri.to_string target + | Some path -> Filename.basename path) + in + DocumentLink.yojson_of_t + (DocumentLink.create + ~range:link.range + ?target:(Option.map target ~f:DocumentUri.of_string) + ()) +;; + +let print_links = Test.print_option_list ~none:"null" print_link + +let%expect_test "resolves a cross-reference into another module" = + let dir = Test.temp_dir "ocamllsp-document-link-" in + let source = + {|(** Wraps {!Helper.describe} for a {!Helper.color}, in particular {!Helper.Red}. *) +let show c = Helper.describe c +|} + in + Test.write_file (Filename.concat dir "dune-project") "(lang dune 2.5)\n"; + Test.write_file (Filename.concat dir "dune") "(library\n (name document_link_files))\n"; + Test.write_file + (Filename.concat dir "helper.ml") + "type color =\n | Red\n\nlet describe Red = \"red\"\n"; + Test.write_file (Filename.concat dir "user.ml") source; + Test.run_command ~cwd:dir "dune build"; + let uri = DocumentUri.of_path (Filename.concat dir "user.ml") in + let stderr = Unix.openfile Test.null_device [ O_WRONLY ] 0 in + let on_notification, diagnostics = Test.drain_diagnostics () in + let handler = Client.Handler.make ~on_notification () in + (Test.run_initialized ~stderr ~handler + @@ fun client -> + let textDocument = + TextDocumentItem.create + ~uri + ~languageId:(LanguageKind.Other "ocaml") + ~version:0 + ~text:source + in + let* () = + Client.notification + client + (TextDocumentDidOpen (DidOpenTextDocumentParams.create ~textDocument)) + in + let textDocument = TextDocumentIdentifier.create ~uri in + let* response = + Client.request client (TextDocumentLink (DocumentLinkParams.create ~textDocument ())) + in + let* resolved = + Fiber.sequential_map (Option.value response ~default:[]) ~f:(fun link -> + Client.request client (TextDocumentLinkResolve link)) + in + print_links (Some resolved); + let* () = Client.request client Shutdown in + let* () = Fiber.Ivar.read diagnostics in + Client.stop client); + Unix.close stderr; + [%expect + {| + [ + { + "range": { + "end": { "character": 28, "line": 0 }, + "start": { "character": 10, "line": 0 } + }, + "target": "file:///helper.ml#L4,5" + }, + { + "range": { + "end": { "character": 50, "line": 0 }, + "start": { "character": 35, "line": 0 } + }, + "target": "file:///helper.ml#L1,6" + }, + { + "range": { + "end": { "character": 79, "line": 0 }, + "start": { "character": 66, "line": 0 } + }, + "target": "file:///helper.ml#L2,5" + } + ] + |}] +;; diff --git a/ocaml-lsp-server/test/e2e-new/dune b/ocaml-lsp-server/test/e2e-new/dune index 1b8af1ed5..bb6d63760 100644 --- a/ocaml-lsp-server/test/e2e-new/dune +++ b/ocaml-lsp-server/test/e2e-new/dune @@ -67,6 +67,8 @@ doc_to_md document_flow document_highlight + document_link + document_link_workspace document_sync document_text_command exit_notification diff --git a/ocaml-lsp-server/test/e2e-new/hover.ml b/ocaml-lsp-server/test/e2e-new/hover.ml index 82a599324..3d048d26a 100644 --- a/ocaml-lsp-server/test/e2e-new/hover.ml +++ b/ocaml-lsp-server/test/e2e-new/hover.ml @@ -445,3 +445,32 @@ let f ({ px; py } as p : point) = px + py } |}] ;; + +(* A cross-reference resolves to the definition it names, so the popup can link + it. The kind qualifying one is not part of what the reader should see. *) +let%expect_test "links cross-references in hover documentation" = + let source = + {ocaml|type t = Foo + +(** Knows whether a {!t} is {!Foo}, unlike {!val:missing}. *) +let is_foo Foo = true +|ocaml} + in + Hover_helpers.test_hover + ~capabilities:Hover_helpers.markdown_capabilities + source + [ Position.create ~line:3 ~character:4 ]; + [%expect + {| + { + "contents": { + "kind": "markdown", + "value": "```ocaml\nt -> bool\n```\n***\nKnows whether a [`t`](file:///test.ml#L1,6) is [`Foo`](file:///test.ml#L1,10), unlike `missing`." + }, + "range": { + "end": { "character": 10, "line": 3 }, + "start": { "character": 4, "line": 3 } + } + } + |}] +;; diff --git a/ocaml-lsp-server/test/e2e-new/start_stop.ml b/ocaml-lsp-server/test/e2e-new/start_stop.ml index 810b1e58a..e3d6a515f 100644 --- a/ocaml-lsp-server/test/e2e-new/start_stop.ml +++ b/ocaml-lsp-server/test/e2e-new/start_stop.ml @@ -168,6 +168,7 @@ let%expect_test "start/stop" = "definitionProvider": true, "documentFormattingProvider": true, "documentHighlightProvider": true, + "documentLinkProvider": { "resolveProvider": true }, "documentRangeFormattingProvider": true, "documentSymbolProvider": true, "executeCommandProvider": {