From d7d4cbc181fff1c2c208222c644df1b1bcd23567 Mon Sep 17 00:00:00 2001 From: Onyeka Obi Date: Thu, 30 Jul 2026 08:55:04 -0700 Subject: [PATCH 1/4] tests: show the outline selection escaping the item range (#2106) When a function with an optional argument is passed to `List.map`, the compiler eta-expands it, and the generated binding carries a dummy name location. The outline reports that dummy location verbatim in its `selection` field (line 0, col -1), outside the item's range. Signed-off-by: Onyeka Obi --- tests/test-dirs/issue2106.t | 94 +++++++++++++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 tests/test-dirs/issue2106.t diff --git a/tests/test-dirs/issue2106.t b/tests/test-dirs/issue2106.t new file mode 100644 index 0000000000..2a61032c53 --- /dev/null +++ b/tests/test-dirs/issue2106.t @@ -0,0 +1,94 @@ +The outline selection range must always be included in the item range +(the LSP protocol requires it). When `f` takes an optional argument and +is passed to `List.map`, the compiler eta-expands it; the generated +binding carries a dummy name location which must not leak into the +`selection` field. See issue #2106. + + $ cat >test.ml < let f ?x _ = x + > let g childs = List.map f childs + > EOF + + $ $MERLIN single outline -filename test.ml 'b option list", + "children": [ + { + "start": { + "line": 2, + "col": 24 + }, + "end": { + "line": 2, + "col": 25 + }, + "name": "arg", + "kind": "Value", + "type": "?x:'a -> 'b -> 'a option", + "children": [], + "deprecated": false, + "selection": { + "start": { + "line": 0, + "col": -1 + }, + "end": { + "line": 0, + "col": -1 + } + } + } + ], + "deprecated": false, + "selection": { + "start": { + "line": 2, + "col": 4 + }, + "end": { + "line": 2, + "col": 5 + } + } + }, + { + "start": { + "line": 1, + "col": 0 + }, + "end": { + "line": 1, + "col": 14 + }, + "name": "f", + "kind": "Value", + "type": "?x:'a -> 'b -> 'a option", + "children": [], + "deprecated": false, + "selection": { + "start": { + "line": 1, + "col": 4 + }, + "end": { + "line": 1, + "col": 5 + } + } + } + ], + "notifications": [] + } From 853e06ff200c8d06fbc06c243ea60cdaacabec94 Mon Sep 17 00:00:00 2001 From: Onyeka Obi Date: Thu, 30 Jul 2026 08:55:04 -0700 Subject: [PATCH 2/4] outline: keep the selection range inside the item range Fall back to the item location whenever the name's location is not included in it, as with the dummy location carried by compiler-generated eta-expansion bindings. The LSP protocol requires a symbol's selection range to be contained in its full range. Fixes #2106 Signed-off-by: Onyeka Obi --- CHANGES.md | 4 ++++ src/analysis/outline.ml | 11 ++++++++++- tests/test-dirs/issue2106.t | 8 ++++---- 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 2862689d51..433b549c8d 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -9,6 +9,10 @@ unreleased submodule no longer hides the opened module (fixes #1748) - Fix occurrences staleness detection when the server is not running at the project's source root. (#2097) + - outline: fall back to the item location for the `selection` range when + the name's location lies outside of it, e.g. the dummy location on the + compiler-generated eta-expansion of a function with an optional argument + (#<2111>, fixes #2106) + ocaml index - Fix staleness detection in the presence of ppxes. (#2110) diff --git a/src/analysis/outline.ml b/src/analysis/outline.ml index 6140a334ae..97d81db91f 100644 --- a/src/analysis/outline.ml +++ b/src/analysis/outline.ml @@ -41,10 +41,19 @@ let name_of_patt = function let mk ?(children = []) ~location ~deprecated outline_kind outline_type (name : string Location.loc) = + (* Compiler-generated bindings such as the eta-expansion of a function + with an optional argument carry a dummy name location. The LSP + protocol requires the selection range to be included in the item + range, so fall back to the item location whenever the name's + location lies outside of it (#2106). *) + let selection = + if Location_aux.included ~into:location name.loc then name.loc + else location + in { Query_protocol.outline_kind; outline_type; location; - selection = name.loc; + selection; children; outline_name = name.txt; deprecated diff --git a/tests/test-dirs/issue2106.t b/tests/test-dirs/issue2106.t index 2a61032c53..dfc2f6d60f 100644 --- a/tests/test-dirs/issue2106.t +++ b/tests/test-dirs/issue2106.t @@ -42,12 +42,12 @@ binding carries a dummy name location which must not leak into the "deprecated": false, "selection": { "start": { - "line": 0, - "col": -1 + "line": 2, + "col": 24 }, "end": { - "line": 0, - "col": -1 + "line": 2, + "col": 25 } } } From 21d4bd3e26162a2834ffd46f6839797a3acc3a9f Mon Sep 17 00:00:00 2001 From: Onyeka Obi Date: Thu, 30 Jul 2026 08:58:44 -0700 Subject: [PATCH 3/4] docs(changes): small edit Signed-off-by: Onyeka Obi --- CHANGES.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGES.md b/CHANGES.md index 433b549c8d..f0fee7a1bb 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -12,7 +12,7 @@ unreleased - outline: fall back to the item location for the `selection` range when the name's location lies outside of it, e.g. the dummy location on the compiler-generated eta-expansion of a function with an optional argument - (#<2111>, fixes #2106) + (#2111, fixes #2106) + ocaml index - Fix staleness detection in the presence of ppxes. (#2110) From 4ef9ff5f6dab0ccf67162fa7056c767ec578dd87 Mon Sep 17 00:00:00 2001 From: Onyeka Obi Date: Sun, 2 Aug 2026 07:16:18 -0700 Subject: [PATCH 4/4] outline: hide bindings that carry no source location The eta-expansion the typechecker synthesizes when a function with an optional argument is passed where a plain arrow is expected introduces a value binding with no counterpart in the source, carrying Location.none. Browse_raw exposes it as an ordinary child of the argument expression, so the outline surfaced a phantom "arg" entry under the enclosing binding. Elide such bindings in get_val_elements, hoisting their children, which belong to the user's expression and may contain genuine bindings. Testing Location.is_none rather than loc_ghost leaves ppx-generated bindings, which usually carry ghost but real locations, unaffected. The selection containment fallback in mk remains as a safety net for other out-of-range name locations. Signed-off-by: Onyeka Obi --- CHANGES.md | 9 +++--- src/analysis/outline.ml | 18 +++++++---- tests/test-dirs/issue2106.t | 60 +++++++++++++++++-------------------- 3 files changed, 46 insertions(+), 41 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index f0fee7a1bb..c592a14a7b 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -9,10 +9,11 @@ unreleased submodule no longer hides the opened module (fixes #1748) - Fix occurrences staleness detection when the server is not running at the project's source root. (#2097) - - outline: fall back to the item location for the `selection` range when - the name's location lies outside of it, e.g. the dummy location on the - compiler-generated eta-expansion of a function with an optional argument - (#2111, fixes #2106) + - outline: hide bindings that carry no source location, such as the + compiler-generated eta-expansion of a function with an optional + argument, keeping their descendants; also fall back to the item + location for the `selection` range when a name's location lies + outside of it (#2111, fixes #2106) + ocaml index - Fix staleness detection in the presence of ppxes. (#2110) diff --git a/src/analysis/outline.ml b/src/analysis/outline.ml index 97d81db91f..d5409111c1 100644 --- a/src/analysis/outline.ml +++ b/src/analysis/outline.ml @@ -41,11 +41,11 @@ let name_of_patt = function let mk ?(children = []) ~location ~deprecated outline_kind outline_type (name : string Location.loc) = - (* Compiler-generated bindings such as the eta-expansion of a function - with an optional argument carry a dummy name location. The LSP - protocol requires the selection range to be included in the item - range, so fall back to the item location whenever the name's - location lies outside of it (#2106). *) + (* The LSP protocol requires the selection range to be included in the + item range. Bindings carrying no location at all are elided from + the outline (see [get_val_elements]); the fallback below remains as + a safety net for any other out-of-range provenance, such as a dummy + name location on a node whose own location is real (#2106). *) let selection = if Location_aux.included ~into:location name.loc then name.loc else location @@ -165,6 +165,14 @@ and get_val_elements node = match node.t_node with | Expression _ -> List.concat_map (Lazy.force node.t_children) ~f:get_val_elements + (* The typechecker synthesizes bindings with no counterpart in the + source, such as the eta-expansion of a function with an optional + argument; they carry [Location.none]. Elide them but keep their + descendants, which belong to the user's expression (#2106). + Bindings with a ghost but real location, as ppxes usually produce, + are unaffected. *) + | Value_binding { vb_loc; _ } when Location.is_none vb_loc -> + List.concat_map (Lazy.force node.t_children) ~f:get_val_elements | Class_expr _ | Class_structure _ -> get_class_elements node | _ -> Option.to_list (summarize node) diff --git a/tests/test-dirs/issue2106.t b/tests/test-dirs/issue2106.t index dfc2f6d60f..9ae4914529 100644 --- a/tests/test-dirs/issue2106.t +++ b/tests/test-dirs/issue2106.t @@ -1,8 +1,7 @@ -The outline selection range must always be included in the item range -(the LSP protocol requires it). When `f` takes an optional argument and -is passed to `List.map`, the compiler eta-expands it; the generated -binding carries a dummy name location which must not leak into the -`selection` field. See issue #2106. +When `f` takes an optional argument and is passed to `List.map`, the +compiler eta-expands it; the generated binding carries no source +location and must not appear in the outline. Descendants coming from +the user's expression must survive the elision. See issue #2106. $ cat >test.ml < let f ?x _ = x @@ -25,33 +24,7 @@ binding carries a dummy name location which must not leak into the "name": "g", "kind": "Value", "type": "'a list -> 'b option list", - "children": [ - { - "start": { - "line": 2, - "col": 24 - }, - "end": { - "line": 2, - "col": 25 - }, - "name": "arg", - "kind": "Value", - "type": "?x:'a -> 'b -> 'a option", - "children": [], - "deprecated": false, - "selection": { - "start": { - "line": 2, - "col": 24 - }, - "end": { - "line": 2, - "col": 25 - } - } - } - ], + "children": [], "deprecated": false, "selection": { "start": { @@ -92,3 +65,26 @@ binding carries a dummy name location which must not leak into the ], "notifications": [] } + +The elided binding's children belong to the user's expression and +must be hoisted, not dropped: `seed` below stays visible under `g`. + + $ cat >test2.ml < let f ?x y = (x, y) + > let g l = List.map (ignore (let seed = 1 in seed); f) l + > EOF + + $ $MERLIN single outline -filename test2.ml jq '[.value[] | {name, children: [.children[].name]}]' + [ + { + "name": "g", + "children": [ + "seed" + ] + }, + { + "name": "f", + "children": [] + } + ]