Skip to content

Commit bc36a12

Browse files
authored
Merge pull request #1266 from elixir-lsp/inlay-hints
Inlay hints: variable types + parameter names (TypeHints facade)
2 parents 5985a23 + 893cd3a commit bc36a12

22 files changed

Lines changed: 3702 additions & 26 deletions

.dialyzer_ignore.exs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@
66
{"lib/language_server/providers/execute_command/restart.ex", :no_return},
77
# @erlang_ex_doc? is true on OTP >= 27. Else branches needed for OTP 26 but appear dead when compiled on OTP 27+.
88
{"lib/language_server/markdown_utils.ex", :pattern_match},
9+
# :elixir_tokenizer.tokenize returns a 6-tuple on 1.17+ but a 5-tuple on 1.16
10+
# (and older forms a 4-tuple); the extra clauses are needed at runtime on old
11+
# Elixirs but appear dead when analyzed against the 1.20 PLT.
12+
{"lib/language_server/providers/inlay_hints.ex", :pattern_match},
913
# Conditional Code.ensure_loaded?/Version.match? branches that dialyzer evaluates statically based on the build environment.
1014
{"lib/launch.ex", :pattern_match},
1115
# Code.Fragment.cursor_context/1 spec in Elixir 1.20 omits :capture_arg, but runtime may still emit it.

.github/workflows/ci.yml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,18 @@ on:
99
- master
1010

1111
jobs:
12+
# Release gate: absolute local path dependencies must never ship. The
13+
# elixir_sense dependency is a git pin (dep_versions.exs), so this job
14+
# hard-fails on any reintroduction of a local path dep.
15+
release-gate:
16+
name: Reject absolute path deps (release gate)
17+
runs-on: ubuntu-22.04
18+
steps:
19+
- uses: actions/checkout@v4
20+
- name: Check for absolute local path dependencies
21+
run: |
22+
! grep -rn 'path: "/' --include=mix.exs --include=mix.lock .
23+
1224
# Smoke test on the highest supported OTP for each Elixir version
1325
smoke_test_language_server:
1426
name: Smoke test language server (Elixir ${{matrix.elixir}} | OTP ${{matrix.otp}})

apps/elixir_ls_utils/lib/completion_engine.ex

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ defmodule ElixirLS.Utils.CompletionEngine do
7171
alias ElixirSense.Core.State.StructInfo
7272
alias ElixirSense.Core.Struct
7373
alias ElixirSense.Core.TypeInfo
74+
alias ElixirSense.Core.TypePresentation
7475

7576
alias ElixirLS.Utils.Matcher
7677

@@ -2021,7 +2022,10 @@ defmodule ElixirLS.Utils.CompletionEngine do
20212022
value_is_map: value_is_map,
20222023
origin: if(subtype == :struct_field and origin != nil, do: inspect(origin)),
20232024
call?: true,
2024-
type_spec: map_field_spec(key, types, origin),
2025+
# Prefer the declared typespec; fall back to the inferred field type
2026+
# rendered from the resolved receiver shape (plain maps, or struct
2027+
# fields without a @type).
2028+
type_spec: map_field_spec(key, types, origin) || rendered_field_type(value),
20252029
summary: doc,
20262030
metadata: meta
20272031
}
@@ -2072,6 +2076,17 @@ defmodule ElixirLS.Utils.CompletionEngine do
20722076
end
20732077
end
20742078

2079+
# Fallback when a field has no declared typespec: render the inferred field
2080+
# type (from the resolved receiver shape) to text. elixir-ls `type_spec` is a
2081+
# string, so render directly (no AST round-trip). Bare `term()`/`none()` carry
2082+
# no information, so they are dropped (a nested `%{a: term()}` is still kept).
2083+
defp rendered_field_type(value) do
2084+
case TypePresentation.render(value) do
2085+
{:ok, text} when text not in ["term()", "none()"] -> text
2086+
_ -> nil
2087+
end
2088+
end
2089+
20752090
## Ad-hoc conversions
20762091
@spec to_entries(map) :: t()
20772092

apps/elixir_ls_utils/test/complete_test.exs

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -917,7 +917,7 @@ defmodule ElixirLS.Utils.CompletionEngineTest do
917917
type: :field,
918918
origin: nil,
919919
call?: true,
920-
type_spec: nil,
920+
type_spec: "String",
921921
value_is_map: false,
922922
summary: "",
923923
metadata: %{}
@@ -945,7 +945,8 @@ defmodule ElixirLS.Utils.CompletionEngineTest do
945945
type: :field,
946946
origin: nil,
947947
call?: true,
948-
type_spec: nil,
948+
type_spec:
949+
"%{deeply: %{foo: term(), bar_1: term(), bar_2: term(), mod: String, num: term()}}",
949950
value_is_map: true,
950951
summary: "",
951952
metadata: %{}
@@ -960,7 +961,8 @@ defmodule ElixirLS.Utils.CompletionEngineTest do
960961
type: :field,
961962
origin: nil,
962963
call?: true,
963-
type_spec: nil,
964+
type_spec:
965+
"%{foo: term(), bar_1: term(), bar_2: term(), mod: String, num: term()}",
964966
value_is_map: true,
965967
summary: "",
966968
metadata: %{}
@@ -1881,7 +1883,7 @@ defmodule ElixirLS.Utils.CompletionEngineTest do
18811883
type: :field,
18821884
origin: "ElixirLS.Utils.CompletionEngineTest.MyStruct",
18831885
call?: true,
1884-
type_spec: nil,
1886+
type_spec: "%{asdf: term()}",
18851887
value_is_map: true,
18861888
summary: "",
18871889
metadata: %{}
@@ -1981,7 +1983,7 @@ defmodule ElixirLS.Utils.CompletionEngineTest do
19811983
type: :field,
19821984
origin: "ElixirLS.Utils.CompletionEngineTest.MyStruct",
19831985
call?: true,
1984-
type_spec: nil,
1986+
type_spec: "%ElixirLS.Utils.CompletionEngineTest.MyStruct{}",
19851987
value_is_map: true,
19861988
summary: "",
19871989
metadata: %{}
@@ -2016,19 +2018,19 @@ defmodule ElixirLS.Utils.CompletionEngineTest do
20162018
end
20172019

20182020
test "completion for bitstring modifiers" do
2019-
assert entries = expand('<<foo::') |> Enum.filter(&(&1[:type] == :bitstring_option))
2021+
assert entries = expand(~c"<<foo::") |> Enum.filter(&(&1[:type] == :bitstring_option))
20202022
assert Enum.any?(entries, &(&1.name == "integer"))
20212023
assert Enum.any?(entries, &(&1.name == "size" and &1.arity == 1))
20222024

2023-
assert [%{name: "integer", type: :bitstring_option}] = expand('<<foo::int')
2025+
assert [%{name: "integer", type: :bitstring_option}] = expand(~c"<<foo::int")
20242026

2025-
assert entries = expand('<<foo::integer-') |> Enum.filter(&(&1[:type] == :bitstring_option))
2027+
assert entries = expand(~c"<<foo::integer-") |> Enum.filter(&(&1[:type] == :bitstring_option))
20262028
refute Enum.any?(entries, &(&1.name == "integer"))
20272029
assert Enum.any?(entries, &(&1.name == "little"))
20282030
assert Enum.any?(entries, &(&1.name == "size" and &1.arity == 1))
20292031

20302032
assert entries =
2031-
expand('<<foo::integer-little-') |> Enum.filter(&(&1[:type] == :bitstring_option))
2033+
expand(~c"<<foo::integer-little-") |> Enum.filter(&(&1[:type] == :bitstring_option))
20322034

20332035
refute Enum.any?(entries, &(&1.name == "integer"))
20342036
refute Enum.any?(entries, &(&1.name == "little"))
@@ -2602,7 +2604,7 @@ defmodule ElixirLS.Utils.CompletionEngineTest do
26022604
# In module context, we should only module functions
26032605
entries =
26042606
expand(
2605-
'@module_attr.',
2607+
~c"@module_attr.",
26062608
module_env,
26072609
metadata
26082610
)
@@ -2613,7 +2615,7 @@ defmodule ElixirLS.Utils.CompletionEngineTest do
26132615
# In def context, we should get both module and function
26142616
entries =
26152617
expand(
2616-
'@module_attr.',
2618+
~c"@module_attr.",
26172619
module_env |> Map.put(:function, {:bar, 0}),
26182620
metadata
26192621
)

apps/language_server/lib/language_server.ex

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,16 @@ defmodule ElixirLS.LanguageServer do
2525
Launch.start_mix()
2626

2727
Application.put_env(:elixir_sense, :logging_enabled, Mix.env() != :prod)
28+
29+
# Apply ELIXIR_LS_TYPE_INFERENCE env var at runtime. This must happen here
30+
# because config.exs is evaluated at build time and has no effect in releases.
31+
use_elixir_types =
32+
System.get_env("ELIXIR_LS_TYPE_INFERENCE", "true")
33+
|> String.downcase()
34+
|> then(&(&1 not in ["false", "0"]))
35+
36+
Application.put_env(:elixir_sense, :use_elixir_types, use_elixir_types)
37+
2838
Build.set_compiler_options()
2939

3040
start_language_server()

apps/language_server/lib/language_server/markdown_utils.ex

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -428,7 +428,16 @@ defmodule ElixirLS.LanguageServer.MarkdownUtils do
428428
@kernel_special_forms_exports Kernel.SpecialForms.__info__(:macros)
429429
@kernel_exports Kernel.__info__(:macros) ++ Kernel.__info__(:functions)
430430

431-
defp get_module_fun_arity("..///3"), do: {Kernel, :..//, 3}
431+
# NOTE: `String.to_atom("..//")` rather than the literal `:..//` atom because
432+
# the two supported formatters disagree on how to render that atom — Elixir
433+
# 1.20 emits it unquoted (`:..//`) while 1.16 quotes it (`:"..//"`), so no
434+
# single literal form is `mix format --check-formatted`-clean on both. The
435+
# string form is left untouched by every formatter and yields the same atom.
436+
# Computed once at compile time (module attribute) to keep the dynamic-atom
437+
# call out of the per-invocation path.
438+
@stepped_range_atom String.to_atom("..//")
439+
440+
defp get_module_fun_arity("..///3"), do: {Kernel, @stepped_range_atom, 3}
432441
defp get_module_fun_arity("../2"), do: {Kernel, :.., 2}
433442
defp get_module_fun_arity("../0"), do: {Kernel, :.., 0}
434443
defp get_module_fun_arity("./2"), do: {Kernel.SpecialForms, :., 2}

apps/language_server/lib/language_server/providers/hover.ex

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,12 +228,29 @@ defmodule ElixirLS.LanguageServer.Providers.Hover do
228228
end
229229

230230
defp format_doc(info = %{kind: :variable}) do
231+
type_section =
232+
case Map.get(info, :type) do
233+
type when is_binary(type) and type != "" ->
234+
"""
235+
236+
### Type
237+
238+
```elixir
239+
#{type}
240+
```
241+
"""
242+
243+
_ ->
244+
""
245+
end
246+
231247
"""
232248
```elixir
233249
#{info.name}
234250
```
235251
236252
*variable*
253+
#{type_section}
237254
"""
238255
end
239256

apps/language_server/lib/language_server/providers/hover/docs.ex

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ defmodule ElixirLS.LanguageServer.Providers.Hover.Docs do
1818
alias ElixirSense.Core.SurroundContext
1919
alias ElixirSense.Core.State.{ModFunInfo, SpecInfo}
2020
alias ElixirSense.Core.TypeInfo
21+
alias ElixirSense.Core.TypePresentation
2122
alias ElixirSense.Core.Parser
2223
alias ElixirSense.Core.Source
2324

@@ -49,7 +50,8 @@ defmodule ElixirLS.LanguageServer.Providers.Hover.Docs do
4950

5051
@type variable_doc :: %{
5152
name: atom(),
52-
kind: :variable
53+
kind: :variable,
54+
type: String.t() | nil
5355
}
5456

5557
@type attribute_doc :: %{
@@ -137,9 +139,16 @@ defmodule ElixirLS.LanguageServer.Providers.Hover.Docs do
137139
var_info = Metadata.find_var(metadata, variable, version, context.begin)
138140

139141
if var_info != nil do
142+
type =
143+
case TypePresentation.render_hint(binding_env, var_info) do
144+
{:ok, text} -> text
145+
:skip -> nil
146+
end
147+
140148
%{
141149
name: Atom.to_string(variable),
142-
kind: :variable
150+
kind: :variable,
151+
type: type
143152
}
144153
else
145154
mod_fun_docs(

0 commit comments

Comments
 (0)