diff --git a/bin/httui-lsp/httui_lsp.ml b/bin/httui-lsp/httui_lsp.ml index 4e0a584..7b8508f 100644 --- a/bin/httui-lsp/httui_lsp.ml +++ b/bin/httui-lsp/httui_lsp.ml @@ -210,7 +210,8 @@ let on_initialize (r : Jsonrpc.Request.t) = ~tokenModifiers:!legend_modifiers) ~full:(`Full (T.SemanticTokensOptions.create_full ~delta:true ())) ())) - () + ~definitionProvider:(`Bool true) ~referencesProvider:(`Bool true) + ~renameProvider:(`Bool true) () in let serverInfo = T.InitializeResult.create_serverInfo ~name:"httui-lsp" @@ -389,6 +390,67 @@ let on_semantic_tokens_delta (r : Jsonrpc.Request.t) = (T.SemanticTokens.yojson_of_t (T.SemanticTokens.create ~data ~resultId:result_id ()))) +(* --- alias navigation: definition / references / rename ----------------- *) + +let location_of_range text uri ~start ~stop = + T.Location.create ~uri ~range:(range_of_offsets text ~start ~stop) + +let on_definition (r : Jsonrpc.Request.t) = + let p = T.DefinitionParams.t_of_yojson (params_json r.params) in + with_doc r.id p.textDocument.uri (fun text -> + let blocks = Httui_lang.Fence_scanner.scan text in + let offset = offset_of_position text p.position in + match Httui_lang.Analyze.symbol_at blocks ~offset with + | Some { decl_range = Some (s, e); _ } -> + let loc = + location_of_range text p.textDocument.uri ~start:s ~stop:e + in + respond r.id (T.Locations.yojson_of_t (`Location [ loc ])) + | _ -> respond r.id `Null) + +let on_references (r : Jsonrpc.Request.t) = + let p = T.ReferenceParams.t_of_yojson (params_json r.params) in + with_doc r.id p.textDocument.uri (fun text -> + let blocks = Httui_lang.Fence_scanner.scan text in + let offset = offset_of_position text p.position in + match Httui_lang.Analyze.symbol_at blocks ~offset with + | None -> respond r.id `Null + | Some sym -> + let ranges = + (if p.context.includeDeclaration then Option.to_list sym.decl_range + else []) + @ sym.ref_ranges + in + let locs = + List.map + (fun (s, e) -> + location_of_range text p.textDocument.uri ~start:s ~stop:e) + ranges + in + respond r.id (`List (List.map T.Location.yojson_of_t locs))) + +let on_rename (r : Jsonrpc.Request.t) = + let p = T.RenameParams.t_of_yojson (params_json r.params) in + with_doc r.id p.textDocument.uri (fun text -> + let blocks = Httui_lang.Fence_scanner.scan text in + let offset = offset_of_position text p.position in + match Httui_lang.Analyze.symbol_at blocks ~offset with + (* positional [$prev] has no name to rewrite *) + | Some { decl_range = Some (ds, de); ref_ranges; alias } + when alias <> Httui_lang.Analyze.prev_name -> + let edits = + List.map + (fun (s, e) -> + T.TextEdit.create ~newText:p.newName + ~range:(range_of_offsets text ~start:s ~stop:e)) + ((ds, de) :: ref_ranges) + in + let edit = + T.WorkspaceEdit.create ~changes:[ (p.textDocument.uri, edits) ] () + in + respond r.id (T.WorkspaceEdit.yojson_of_t edit) + | _ -> respond r.id `Null) + let shutdown_received = ref false let handle_request (r : Jsonrpc.Request.t) = @@ -398,6 +460,9 @@ let handle_request (r : Jsonrpc.Request.t) = | "textDocument/completion" -> on_completion r | "textDocument/semanticTokens/full" -> on_semantic_tokens r | "textDocument/semanticTokens/full/delta" -> on_semantic_tokens_delta r + | "textDocument/definition" -> on_definition r + | "textDocument/references" -> on_references r + | "textDocument/rename" -> on_rename r | "shutdown" -> shutdown_received := true; respond r.id `Null diff --git a/lib/analyze.ml b/lib/analyze.ml index fda2a21..b439437 100644 --- a/lib/analyze.ml +++ b/lib/analyze.ml @@ -192,6 +192,99 @@ let block_index_at blocks ~offset = in go 0 blocks +(* --- alias navigation (definition / references / rename) ---------------- *) + +type alias_symbol = { + alias : string; + decl_range : (int * int) option; + (** doc-absolute range of the alias value in the declaring block's info + string, when one is in scope *) + ref_ranges : (int * int) list; + (** doc-absolute name-token range of every [{{alias...}}] that resolves to + that same declaration *) +} + +(* The block that declares [name] as seen from block [index]: the nearest + aliased block strictly above. For [$prev] it is the nearest executable + aliased block (mirrors the runtime). Returns its index. *) +let decl_index_for blocks ~index name = + let arr = Array.of_list blocks in + let rec back j = + if j < 0 then None + else + let (b : Block.t) = arr.(j) in + let matches = + if name = prev_name then Block.is_executable b && b.alias <> None + else b.alias = Some name + in + if matches then Some j else back (j - 1) + in + back (index - 1) + +(* Resolve the alias symbol under [offset]: the cursor may sit on the + alias value in a fence info string (a declaration) or on the name + token of a [{{alias...}}] reference. Path segments and prose are not + alias tokens, so they yield [None] — rename never touches text that + merely looks like an alias. *) +let symbol_at blocks ~offset = + let arr = Array.of_list blocks in + let on_decl = + let n = Array.length arr in + let rec find i = + if i >= n then None + else + let (b : Block.t) = arr.(i) in + match (b.alias, b.alias_offset) with + | Some a, Some ao when offset >= ao && offset <= ao + String.length a -> + Some (i, a) + | _ -> find (i + 1) + in + find 0 + in + let found = + match on_decl with + | Some (i, a) -> Some (i, a) + | None -> ( + match block_index_at blocks ~offset with + | None -> None + | Some (i, b) -> + Refs.of_block b + |> List.find_opt (fun (r : Refs.occurrence) -> + offset >= r.name_start && offset <= r.name_stop) + |> Option.map (fun (r : Refs.occurrence) -> (i, r.name))) + in + match found with + | None -> None + | Some (index, alias) -> + let decl_idx = + if on_decl <> None then Some index + else decl_index_for blocks ~index alias + in + let decl_range = + Option.bind decl_idx (fun d -> + let (b : Block.t) = arr.(d) in + match (b.alias, b.alias_offset) with + | Some a, Some ao -> Some (ao, ao + String.length a) + | _ -> None) + in + let ref_ranges = + match decl_idx with + | None -> [] + | Some d -> + List.concat + (List.mapi + (fun j (b : Block.t) -> + Refs.of_block b + |> List.filter_map (fun (r : Refs.occurrence) -> + if + r.name = alias + && decl_index_for blocks ~index:j r.name = Some d + then Some (r.name_start, r.name_stop) + else None)) + blocks) + in + Some { alias; decl_range; ref_ranges } + let fields_markdown fields = fields |> List.map (fun (k, s) -> Printf.sprintf "- `%s`: %s" k (Shape.type_name s)) diff --git a/test/test_lsp.ml b/test/test_lsp.ml index c57746b..e0635eb 100644 --- a/test/test_lsp.ml +++ b/test/test_lsp.ml @@ -260,6 +260,148 @@ let () = check "semantic tokens data non-empty, stride 5" (List.length data > 0 && List.length data mod 5 = 0); + (* --- alias navigation: definition / references / rename --- + req1 declared on line 2; line 7 has two refs, the second of which + ([{{req1.req1}}]) carries a path segment named like the alias — + rename must not touch it. *) + let nav_doc = + "# nav\n\n\ + ```http alias=req1\n\ + GET https://api.example.com/users\n\ + ```\n\n\ + ```http alias=req2\n\ + GET https://x.dev/{{req1.body.id}}?a={{req1.req1}}\n\ + ```\n" + in + send + (notif "textDocument/didChange" + ~params: + (`Assoc + [ + ( "textDocument", + `Assoc [ ("uri", `String "file:///t.md"); ("version", `Int 3) ] + ); + ("contentChanges", `List [ `Assoc [ ("text", `String nav_doc) ] ]); + ])); + let _ = recv () in + (* cursor inside the first [{{req1...}}] name token on line 7 (char 20 + is the start of [req1]) *) + let ref_pos = `Assoc [ ("line", `Int 7); ("character", `Int 21) ] in + let start_of loc = Option.bind (member "range" loc) (member "start") in + (* definition jumps to the alias value on the declaration line (line 2, + char 14 — start of [req1] in [alias=req1]) *) + send + (req 10 "textDocument/definition" + ~params:(`Assoc [ ("textDocument", text_doc); ("position", ref_pos) ])); + let def = recv () in + check "definition returns the declaration location" + (match member "result" def with + | Some (`List [ loc ]) -> + let s = start_of loc in + Option.bind s (member "line") = Some (`Int 2) + && Option.bind s (member "character") = Some (`Int 14) + | _ -> false); + + (* references with includeDeclaration counts decl + both ref names = 3 *) + send + (req 11 "textDocument/references" + ~params: + (`Assoc + [ + ("textDocument", text_doc); + ("position", ref_pos); + ("context", `Assoc [ ("includeDeclaration", `Bool true) ]); + ])); + let refs_incl = recv () in + check "references incl declaration = 3" + (match member "result" refs_incl with + | Some (`List l) -> List.length l = 3 + | _ -> false); + send + (req 12 "textDocument/references" + ~params: + (`Assoc + [ + ("textDocument", text_doc); + ("position", ref_pos); + ("context", `Assoc [ ("includeDeclaration", `Bool false) ]); + ])); + check "references excl declaration = 2" + (match member "result" (recv ()) with + | Some (`List l) -> List.length l = 2 + | _ -> false); + + (* rename edits the decl + both ref names, NOT the path segment that is + spelled like the alias *) + send + (req 13 "textDocument/rename" + ~params: + (`Assoc + [ + ("textDocument", text_doc); + ("position", ref_pos); + ("newName", `String "fetchUser"); + ])); + let ren = recv () in + let edits = + match + Option.bind + (Option.bind (member "result" ren) (member "changes")) + (member "file:///t.md") + with + | Some (`List l) -> l + | _ -> [] + in + check "rename produces 3 edits (decl + 2 ref names, not the path segment)" + (List.length edits = 3); + check "every rename edit writes the new name" + (List.for_all + (fun e -> member "newText" e = Some (`String "fetchUser")) + edits); + + (* positional [$prev]: definition resolves to the previous block, but + rename is a no-op (it has no name token to rewrite) *) + let prev_doc = + "# p\n\n\ + ```http alias=req1\n\ + GET https://api.example.com/users\n\ + ```\n\n\ + ```http alias=req2\n\ + GET https://x.dev/{{$prev.body.id}}\n\ + ```\n" + in + send + (notif "textDocument/didChange" + ~params: + (`Assoc + [ + ( "textDocument", + `Assoc [ ("uri", `String "file:///t.md"); ("version", `Int 4) ] + ); + ("contentChanges", `List [ `Assoc [ ("text", `String prev_doc) ] ]); + ])); + let _ = recv () in + let prev_pos = `Assoc [ ("line", `Int 7); ("character", `Int 21) ] in + send + (req 14 "textDocument/definition" + ~params:(`Assoc [ ("textDocument", text_doc); ("position", prev_pos) ])); + check "definition on $prev resolves to the previous block" + (match member "result" (recv ()) with + | Some (`List [ loc ]) -> + Option.bind (start_of loc) (member "line") = Some (`Int 2) + | _ -> false); + send + (req 15 "textDocument/rename" + ~params: + (`Assoc + [ + ("textDocument", text_doc); + ("position", prev_pos); + ("newName", `String "whatever"); + ])); + check "rename on $prev is a no-op (null result)" + (member "result" (recv ()) = Some `Null); + (* shutdown / exit *) send (req 9 "shutdown"); let _ = recv () in