Skip to content

feat: add hex intelligence#570

Open
dbernheisel wants to merge 4 commits intoexpert-lsp:mainfrom
dbernheisel:dbern/hex-cmp
Open

feat: add hex intelligence#570
dbernheisel wants to merge 4 commits intoexpert-lsp:mainfrom
dbernheisel:dbern/hex-cmp

Conversation

@dbernheisel
Copy link
Copy Markdown

@dbernheisel dbernheisel commented Apr 11, 2026

Adds hex package intelligence to mix.exs — package name completion, version completion, dep option suggestions, and hover documentation. Works across the public hex.pm registry and custom self-hosted repos like Oban with no configuration beyond what Mix already knows.

This is fairly self-contained with a few integrated points, so because of that, I felt a large PR would be ok for completeness. Happy to refactor/reorganize modules as you see fit.

Generally, this is porting hex-cmp

What it does

  • Package completion — type {:phoe and get phoenix, phoenix_live_view, etc. with download counts and descriptions. Custom repo packages (like oban_pro) appear alongside hexpm results with their repo labeled.
  • Version completion — type {:phoenix, "~> 1. and get filtered version candidates sorted newest-first. Retired versions are annotated with the retirement reason. Works for custom repos without needing repo: typed first.
  • Dep option completion — type {:phoenix, "~> 1.7", and get only, runtime, override, repo, etc.
  • Hover docs — hover a package atom to see description, installed vs latest version, license, download count, and links. For custom repos, metadata is extracted from locally-cached tarballs.

How it works

  • Engine.Deps runs in the project's Mix context (where the Hex archive is loaded) and exposes repo config, dep versions, project files, and cached tarball paths over RPC.
  • Hex.Context detects the cursor slot (name/version/opts) inside a deps function tuple using Sourceror's error-recovering parser, with a regex line fallback for cases where both parsers fail (unclosed strings). This assumes the conventional "deps" function within the project file (eg, mix.exs), and will not offer intelligence otherwise.
  • Hex.Api routes through the hex.pm HTTP API via :hex_core dep for public packages and the repo protocol (:hex_repo) for custom repos. Custom repo responses are enriched with tarball metadata if they're already cached.
  • Hex.Cache is a DETS-backed GenServer with 1-hour TTL and stale-on-error fallback so completions survive network failures and LSP restarts. This helps relieve the server from being hammered with repeated known info from the user.

Testing

  • Verified with a custom repo, eg, oban.
  • Verified with public packages
  • Verified with private hex repos.
  • Verified with umbrella apps, but it should work since it uses the engine's current InProject helpers.
  • Verified with projects with a non-standard project file, eg, not named mix.exs
image image image image image image

Comment on lines 20 to -25
document = Document.Container.context_document(params, nil)
projects = ActiveProjects.projects()
project = Project.project_for_document(projects, document)

document = Document.Container.context_document(params, nil)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not related, but a duplicated call (document is set on line 20 already)

@dbernheisel dbernheisel changed the title Add Hex intelligence feat: add hex intelligence Apr 11, 2026
@andyl
Copy link
Copy Markdown

andyl commented Apr 12, 2026

@dbernheisel - if this is accepted, will we still need hex-cmp ?

ps hex-cmp has been very handy thanks for this great tool!

@dbernheisel
Copy link
Copy Markdown
Author

Nope! It's more complete within Expert; supports custom repos, displays the installed version of the dep, and has access to the projects actual hex to know these things rather than reinventing what mix/hex do.

Copy link
Copy Markdown
Contributor

@katafrakt katafrakt left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tested it and it works really well! Just left some comments, mostly about minor things.

end

defp project_files(%Project{} = project) do
key = {__MODULE__, :project_files, Project.name(project)}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's better to use project.root_uri for cache keys, similar to #575

Comment thread apps/engine/lib/engine/deps.ex Outdated
end

def dep_version(app) when is_atom(app) do
case Application.spec(app, :vsn) do
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This only returns dependencies that are actually loaded in the engine node, which means it does not return anything for deps with runtime: false or only: :dev (because IIRC engine node is run in test env). I actually noticed that while testing, before reading the code.

I wonder if it would make sense to use something like Mix.Dep.Lock.read, either as an alternative or as fallback.

Here's a dev-only dep (no remote version info):

Image

and here's one with [:dev, :test]:

Image

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good catch. I switched over to using the lock file instead, and falling back onto the Application.spec

Comment on lines +199 to +200
def project_scope(nil), do: :__local__
def project_scope(%Project{} = project), do: Project.name(project)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These should be private perhaps?

And also wondering if project.root_uri would not be better choice for a cache key here.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes this is a better choice. I changed it to use project.root_uri

Comment thread apps/engine/test/engine/deps_test.exs Outdated
Comment on lines +32 to +34
test "returns a list (possibly empty)" do
assert is_list(Deps.project_files())
end
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure this test is actually useful

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you're right. I deleted it.

Comment thread apps/engine/test/engine/deps_test.exs Outdated
Comment on lines +25 to +28
test "returns a string or nil (doesn't crash when Mix context is partial)" do
refute Deps.project_file(:app)
refute Deps.project_file(:umbrella)
end
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The assertions and the test description here don't match. I think the biggest value is checking that it does not crash, so something like:

Suggested change
test "returns a string or nil (doesn't crash when Mix context is partial)" do
refute Deps.project_file(:app)
refute Deps.project_file(:umbrella)
end
test "doesn't crash when Mix context is partial" do
Deps.project_file(:app)
Deps.project_file(:umbrella)
end

(not sure how others feel about test cases without assertions though)

@katafrakt
Copy link
Copy Markdown
Contributor

One additional thing I noticed is that the changes here don't play nice with expert engine clean. I'm getting this:

❯ ~/.local/bin/expert_darwin_arm64 engine clean --force
Kernel pid terminated (application_controller) ("{application_start_failure,xp_expert,{bad_return,{{'Elixir.XPExpert.Application',start,[normal,[]]},{'EXIT',{#{reason => enotdir,path => <<\"/Users/pawel.sw/Library/Caches/expert/hex.dets\">>,'__struct__' => 'Elixir.File.Error','__exception__' => true,action => <<\"list directory\">>},[{'Elixir.File','ls!',1,[{file,\"lib/file.ex\"},{line,2064}]},{'Elixir.XPExpert.Engine','-get_engine_dirs/0-fun-1-',3,[{file,\"lib/expert/engine.ex\"},{line,103}]},{'Elixir.Enum','-reduce/3-lists^foldl/2-0-',3,[{file,\"lib/enum.ex\"},{line,2520}]},{'Elixir.XPExpert.Engine',get_engine_dirs,0,[{file,\"lib/expert/engine.ex\"},{line,102}]},{'Elixir.XPExpert.Engine',clean_engines,1,[{file,\"lib/expert/engine.ex\"},{line,80}]},{'Elixir.XPExpert.Application',start,2,[{file,\"lib/expert/application.ex\"},{line,26}]},{application_master,start_it_old,4,[{file,\"application_master.erl\"},{line,299}]}]}}}}}")

Crash dump is being written to: erl_crash.dump...done

Looks like the code for clean only handles directories under expert dir. I think there's no reason to delete hex.dets on clean, so we need to just skip it. Not sure it needs to happen in this PR, which is already quite large. Just a prerequisite to merge this one.

doorgan pushed a commit that referenced this pull request Apr 24, 2026
This was found in a #570 , which added DETS file inside the cache
directory. Then when `engine clean` was run, it failed because it could
only handle directories.

The failure looked like this:

```
❯ ~/.local/bin/expert_darwin_arm64 engine clean --force
Kernel pid terminated (application_controller) ("{application_start_failure,xp_expert,{bad_return,{{'Elixir.XPExpert.Application',start,[normal,[]]},{'EXIT',{#{reason => enotdir,path => <<\"/Users/pawel.sw/Library/Caches/expert/hex.dets\">>,'__struct__' => 'Elixir.File.Error','__exception__' => true,action => <<\"list directory\">>},[{'Elixir.File','ls!',1,[{file,\"lib/file.ex\"},{line,2064}]},{'Elixir.XPExpert.Engine','-get_engine_dirs/0-fun-1-',3,[{file,\"lib/expert/engine.ex\"},{line,103}]},{'Elixir.Enum','-reduce/3-lists^foldl/2-0-',3,[{file,\"lib/enum.ex\"},{line,2520}]},{'Elixir.XPExpert.Engine',get_engine_dirs,0,[{file,\"lib/expert/engine.ex\"},{line,102}]},{'Elixir.XPExpert.Engine',clean_engines,1,[{file,\"lib/expert/engine.ex\"},{line,80}]},{'Elixir.XPExpert.Application',start,2,[{file,\"lib/expert/application.ex\"},{line,26}]},{application_master,start_it_old,4,[{file,\"application_master.erl\"},{line,299}]}]}}}}}")

Crash dump is being written to: erl_crash.dump...done
```

This PR makes the `clean` command skip any files in the cache directory.
@mhanberg
Copy link
Copy Markdown
Member

Could you go over this code again manually, there seems to be a lot of pointless wrapping and unwrapping of return values, unnecessary intermediate variables, and some basic things.

I imagine the design can be improved and minimized with a closer inspection.

@dbernheisel
Copy link
Copy Markdown
Author

Could you go over this code again manually, there seems to be a lot of pointless wrapping and unwrapping of return values, unnecessary intermediate variables, and some basic things.

I imagine the design can be improved and minimized with a closer inspection.

I did before I opened it and a couple times after, and I didn't see anything obvious. There are some "safe" rescues and catches that are annoying but they're at RPC boundaries that seem reasonable.

Feel free to modify it as you wish.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants