Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 66 additions & 1 deletion bin/httui-lsp/httui_lsp.ml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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) =
Expand All @@ -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
Expand Down
93 changes: 93 additions & 0 deletions lib/analyze.ml
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
142 changes: 142 additions & 0 deletions test/test_lsp.ml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading