Skip to content
Draft
Show file tree
Hide file tree
Changes from 26 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
3853dda
Add experimental type inlay hints provider
lukaszsamson Jun 6, 2026
851f8cd
Rewire inlay hints to ElixirSense.Core.TypePresentation
lukaszsamson Jun 7, 2026
f237cb1
Add call parameter-name inlay hints
lukaszsamson Jun 7, 2026
25c4bab
Harden inlay hints: dynamic receivers, range pre-filter, file guard, …
lukaszsamson Jun 7, 2026
05bd00c
Adapt type refinement to hover and completion providers
lukaszsamson Jun 7, 2026
73e3e38
Enable ElixirSense native type inference by default
lukaszsamson Jun 7, 2026
ad248e2
Skip variable hints when binding RHS is an obvious literal
lukaszsamson Jun 7, 2026
d59862f
Suppress variable hints for obvious value on either side of a match
lukaszsamson Jun 7, 2026
a258201
Fix inlay hints audit findings; runtime inference toggle
lukaszsamson Jun 11, 2026
38f886e
Inlay hints: trust-aware gating, backend-status log, GPT-audit tests
lukaszsamson Jun 11, 2026
c32cff2
docs: third review pass — consolidated, verification-backed inlay-hin…
lukaszsamson Jun 11, 2026
9824b69
Inlay hints: consume TypeHints facade; server e2e + ExCk integration …
lukaszsamson Jun 11, 2026
372c929
Inlay hints: minimumTrust via trust ranks; ExCk + destructuring coverage
lukaszsamson Jun 11, 2026
4ae56da
docs: mark wave-3 items done in the FABLE backlog
lukaszsamson Jun 11, 2026
5c28ec3
Inlay hints: warn on unrecognized minimumTrust; precompute trust rank
lukaszsamson Jun 11, 2026
97ebd2c
Inlay hints: flow-sensitive read hints; release-gate CI job
lukaszsamson Jun 11, 2026
ce48171
Inlay hints: dependency-chain ExCk fixtures; release smoke scaffold
lukaszsamson Jun 12, 2026
2ffa2fb
Repoint elixir_sense to the published branch commit
lukaszsamson Jun 12, 2026
1523b33
Bump elixir_sense pin to c9f34e24 (version-matrix hardening — CI gree…
lukaszsamson Jun 12, 2026
2e86b74
Merge remote-tracking branch 'origin/master' into inlay-hints
lukaszsamson Jun 12, 2026
0cac056
Multi-version hardening: 1.16 tokenizer fix, format stability, log-fl…
lukaszsamson Jun 12, 2026
00ccd6d
Bump elixir_sense pin to ff85146a (recursive @spec guard expansion fi…
lukaszsamson Jun 12, 2026
73e37d8
Bump elixir_sense pin to b3ad3c30 (dialyzer opaque fix)
lukaszsamson Jun 12, 2026
7c576bc
Fix dialyzer findings in inlay hints provider
lukaszsamson Jun 12, 2026
ae7c448
docs: remove development-time plans and review documents
lukaszsamson Jun 12, 2026
10d3e5d
Inlay hints: resolve receivers via ModuleResolver; O(K) argument spli…
lukaszsamson Jun 12, 2026
ab715db
inlay hints: pass plain map to ModuleResolver.resolve (dialyzer); bum…
lukaszsamson Jun 13, 2026
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
4 changes: 4 additions & 0 deletions .dialyzer_ignore.exs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@
{"lib/language_server/providers/execute_command/restart.ex", :no_return},
# @erlang_ex_doc? is true on OTP >= 27. Else branches needed for OTP 26 but appear dead when compiled on OTP 27+.
{"lib/language_server/markdown_utils.ex", :pattern_match},
# :elixir_tokenizer.tokenize returns a 6-tuple on 1.17+ but a 5-tuple on 1.16
# (and older forms a 4-tuple); the extra clauses are needed at runtime on old
# Elixirs but appear dead when analyzed against the 1.20 PLT.
{"lib/language_server/providers/inlay_hints.ex", :pattern_match},
# Conditional Code.ensure_loaded?/Version.match? branches that dialyzer evaluates statically based on the build environment.
{"lib/launch.ex", :pattern_match},
# Code.Fragment.cursor_context/1 spec in Elixir 1.20 omits :capture_arg, but runtime may still emit it.
Expand Down
15 changes: 15 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,21 @@ on:
- master

jobs:
# Release gate: absolute local path dependencies must never ship. This is
# EXPECTED to flag on the inlay-hints development branch (elixir_sense is a
# local worktree path dep during development) — continue-on-error keeps the
# branch green while documenting the blocker. Flip continue-on-error to
# false before release.
Comment on lines +12 to +16
release-gate:
name: Reject absolute path deps (release gate)
runs-on: ubuntu-22.04
continue-on-error: true
steps:
- uses: actions/checkout@v4
- name: Check for absolute local path dependencies
run: |
! grep -rn 'path: "/' --include=mix.exs --include=mix.lock .

# Smoke test on the highest supported OTP for each Elixir version
smoke_test_language_server:
name: Smoke test language server (Elixir ${{matrix.elixir}} | OTP ${{matrix.otp}})
Expand Down
17 changes: 16 additions & 1 deletion apps/elixir_ls_utils/lib/completion_engine.ex
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ defmodule ElixirLS.Utils.CompletionEngine do
alias ElixirSense.Core.State.StructInfo
alias ElixirSense.Core.Struct
alias ElixirSense.Core.TypeInfo
alias ElixirSense.Core.TypePresentation

alias ElixirLS.Utils.Matcher

Expand Down Expand Up @@ -1907,7 +1908,10 @@ defmodule ElixirLS.Utils.CompletionEngine do
value_is_map: value_is_map,
origin: if(subtype == :struct_field and origin != nil, do: inspect(origin)),
call?: true,
type_spec: map_field_spec(key, types, origin),
# Prefer the declared typespec; fall back to the inferred field type
# rendered from the resolved receiver shape (plain maps, or struct
# fields without a @type).
type_spec: map_field_spec(key, types, origin) || rendered_field_type(value),
summary: doc,
metadata: meta
}
Expand Down Expand Up @@ -1958,6 +1962,17 @@ defmodule ElixirLS.Utils.CompletionEngine do
end
end

# Fallback when a field has no declared typespec: render the inferred field
# type (from the resolved receiver shape) to text. elixir-ls `type_spec` is a
# string, so render directly (no AST round-trip). Bare `term()`/`none()` carry
# no information, so they are dropped (a nested `%{a: term()}` is still kept).
defp rendered_field_type(value) do
case TypePresentation.render(value) do
{:ok, text} when text not in ["term()", "none()"] -> text
_ -> nil
end
end

## Ad-hoc conversions
@spec to_entries(map) :: t()

Expand Down
24 changes: 13 additions & 11 deletions apps/elixir_ls_utils/test/complete_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -917,7 +917,7 @@ defmodule ElixirLS.Utils.CompletionEngineTest do
type: :field,
origin: nil,
call?: true,
type_spec: nil,
type_spec: "String",
value_is_map: false,
summary: "",
metadata: %{}
Expand Down Expand Up @@ -945,7 +945,8 @@ defmodule ElixirLS.Utils.CompletionEngineTest do
type: :field,
origin: nil,
call?: true,
type_spec: nil,
type_spec:
"%{deeply: %{foo: term(), bar_1: term(), bar_2: term(), mod: String, num: term()}}",
value_is_map: true,
summary: "",
metadata: %{}
Expand All @@ -960,7 +961,8 @@ defmodule ElixirLS.Utils.CompletionEngineTest do
type: :field,
origin: nil,
call?: true,
type_spec: nil,
type_spec:
"%{foo: term(), bar_1: term(), bar_2: term(), mod: String, num: term()}",
value_is_map: true,
summary: "",
metadata: %{}
Expand Down Expand Up @@ -1881,7 +1883,7 @@ defmodule ElixirLS.Utils.CompletionEngineTest do
type: :field,
origin: "ElixirLS.Utils.CompletionEngineTest.MyStruct",
call?: true,
type_spec: nil,
type_spec: "%{asdf: term()}",
value_is_map: true,
summary: "",
metadata: %{}
Expand Down Expand Up @@ -1981,7 +1983,7 @@ defmodule ElixirLS.Utils.CompletionEngineTest do
type: :field,
origin: "ElixirLS.Utils.CompletionEngineTest.MyStruct",
call?: true,
type_spec: nil,
type_spec: "%ElixirLS.Utils.CompletionEngineTest.MyStruct{}",
value_is_map: true,
summary: "",
metadata: %{}
Expand Down Expand Up @@ -2016,19 +2018,19 @@ defmodule ElixirLS.Utils.CompletionEngineTest do
end

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

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

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

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

refute Enum.any?(entries, &(&1.name == "integer"))
refute Enum.any?(entries, &(&1.name == "little"))
Expand Down Expand Up @@ -2576,7 +2578,7 @@ defmodule ElixirLS.Utils.CompletionEngineTest do
# In module context, we should only module functions
entries =
expand(
'@module_attr.',
~c"@module_attr.",
module_env,
metadata
)
Expand All @@ -2587,7 +2589,7 @@ defmodule ElixirLS.Utils.CompletionEngineTest do
# In def context, we should get both module and function
entries =
expand(
'@module_attr.',
~c"@module_attr.",
module_env |> Map.put(:function, {:bar, 0}),
metadata
)
Expand Down
10 changes: 10 additions & 0 deletions apps/language_server/lib/language_server.ex
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,16 @@ defmodule ElixirLS.LanguageServer do
Launch.start_mix()

Application.put_env(:elixir_sense, :logging_enabled, Mix.env() != :prod)

# Apply ELIXIR_LS_TYPE_INFERENCE env var at runtime. This must happen here
# because config.exs is evaluated at build time and has no effect in releases.
use_elixir_types =
System.get_env("ELIXIR_LS_TYPE_INFERENCE", "true")
|> String.downcase()
|> then(&(&1 not in ["false", "0"]))

Application.put_env(:elixir_sense, :use_elixir_types, use_elixir_types)

Build.set_compiler_options()

start_language_server()
Expand Down
7 changes: 6 additions & 1 deletion apps/language_server/lib/language_server/markdown_utils.ex
Original file line number Diff line number Diff line change
Expand Up @@ -428,7 +428,12 @@ defmodule ElixirLS.LanguageServer.MarkdownUtils do
@kernel_special_forms_exports Kernel.SpecialForms.__info__(:macros)
@kernel_exports Kernel.__info__(:macros) ++ Kernel.__info__(:functions)

defp get_module_fun_arity("..///3"), do: {Kernel, :..//, 3}
# NOTE: `String.to_atom("..//")` rather than the literal `:..//` atom because
# the two supported formatters disagree on how to render that atom — Elixir
# 1.20 emits it unquoted (`:..//`) while 1.16 quotes it (`:"..//"`), so no
# single literal form is `mix format --check-formatted`-clean on both. The
# string form is left untouched by every formatter and yields the same atom.
defp get_module_fun_arity("..///3"), do: {Kernel, String.to_atom("..//"), 3}
Comment on lines +431 to +436
defp get_module_fun_arity("../2"), do: {Kernel, :.., 2}
defp get_module_fun_arity("../0"), do: {Kernel, :.., 0}
defp get_module_fun_arity("./2"), do: {Kernel.SpecialForms, :., 2}
Expand Down
17 changes: 17 additions & 0 deletions apps/language_server/lib/language_server/providers/hover.ex
Original file line number Diff line number Diff line change
Expand Up @@ -228,12 +228,29 @@ defmodule ElixirLS.LanguageServer.Providers.Hover do
end

defp format_doc(info = %{kind: :variable}) do
type_section =
case Map.get(info, :type) do
type when is_binary(type) and type != "" ->
"""

### Type

```elixir
#{type}
```
"""

_ ->
""
end
Comment on lines +231 to +245

"""
```elixir
#{info.name}
```

*variable*
#{type_section}
"""
end

Expand Down
13 changes: 11 additions & 2 deletions apps/language_server/lib/language_server/providers/hover/docs.ex
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ defmodule ElixirLS.LanguageServer.Providers.Hover.Docs do
alias ElixirSense.Core.SurroundContext
alias ElixirSense.Core.State.{ModFunInfo, SpecInfo}
alias ElixirSense.Core.TypeInfo
alias ElixirSense.Core.TypePresentation
alias ElixirSense.Core.Parser
alias ElixirSense.Core.Source

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

@type variable_doc :: %{
name: atom(),
kind: :variable
kind: :variable,
type: String.t() | nil
}

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

if var_info != nil do
type =
case TypePresentation.render_hint(binding_env, var_info) do
{:ok, text} -> text
:skip -> nil
end

%{
name: Atom.to_string(variable),
kind: :variable
kind: :variable,
type: type
}
else
mod_fun_docs(
Expand Down
Loading
Loading