ExHelp - documentation fuzzy search and paging CLI tool for Elixir/Erlang

IEx’s h macro is great but it lacks a pager, so I built a small tool that caches documentation and lets you fuzzy search through it in your OS shell.

It follows the Unix philosophy - Internally pipes to fzf and your default $PAGER. The cache lives at ~/.cache/exh and builds up over time as you workon different projects (no duplicates).

It compiles your project, loads all the available modules, and encodes the documentation using ETF.

Performance-wise, it caches a vanilla Phoenix project in about 10 seconds on first run, 3 seconds on subsequent runs. Tested on my old T420.

The formatting of the docs matches IEx’s `h` macro since I borrowed some of that code.

Example usage:

$ exh fetch
$ exh

Originally tried making it IEx-native using Ports (check the less-in-iex branch), but, despite redirecting the group leader and waiting for the Port’s exit message, couldn’t find a way to make the BEAM fully yield terminal control - resulted in race conditions between the BEAM and the TUI. If anyone has insights on this, I’m all ears!

Feedback welcome!

5 Likes

This solves a pain point I’ve been experiencing a lot, and never got a chance to dig in. (Too busy scrolling up after calling help!)

Looking forward to trying it out.

1 Like

I feel you! Let me know if everything works smoothly or not.

1 Like

Interesting! How do you deal with versioning?

E.g., the help for a certain module/ function may change depending on the current branch/commit, in particular for “in development” state, not between tagged releases.

The cache invalidation problem :slight_smile:

2 Likes

Good question, I haven’t thought of that until now. The currently supported solution would be to exh clean and exh fetch.

To make it more efficient, my first thought is to create a key-value store with the module names and a checksum for each module. This way, the caching algorithm can check the validity of each existing cached module when re-caching and clean up stale cache.

Also, validating against .tool-versions and mix.lock could be great.

How useful do you think each approach would be to you? Is cleaning + fetching fine for your use-case?

Does the cache speed improve on subsequent runs because it’s only compiling user land code? Whereas first run also caches and prepares dependencies?

1 Like

The improvement comes from not having to perform disk IO for modules that have already been cached previously.
It will always iterate over every available module to the project: erlang/elixir installation, dependencies, and source code.

I find myself consuming docs most of the time from hexdocs.pm or source code. When I do use h inside IEx, I’m typically inside tmux and can “scroll” back up with the keyboard.

So, speaking hypothetically, if exh clean nukes the whole cache, it would be too aggressive if I just want to refresh docs for a particular project. A refetch or fetch --force would be more targeted.

I still wonder how are you structuring the cache. Do you shard by package version? How do you treat Elixir and Erlang documentation?

This idea of centrally storing a deduplicated cache of packages reminds me of the Go Module Cache. In the Go ecosystem published modules have an immutable cryptographic checksum, and the multiple versions of a package are cached read-only in a single place. Separately, there’s also a build cache. How I’d translate that idea to Elixir is that published Hex packages could be cached by version, while “current project” needs special treatment as it can change anytime without a version change.

1 Like

Have you seen the new mix help task in Elixir 1.19?

“[mix help] Add mix help Mod, mix help :mod, mix help Mod.fun, mix help Mod.fun/arity, and mix help app:package”

It’s not interactive, but trivial to combine with fzf and a pager.

A refetch or fetch --force would be more targeted.

This sound very reasonable, I am going to add it.

I still wonder how are you structuring the cache. Do you shard by package version? How do you treat Elixir and Erlang documentation?

The cache structure is just a flat dir. There is a “tags” file that works as the index for fzf, then each cached module has its own file. Maybe it is a bit naive, if the re-caching process proves cumbersome I will think about something more complex.

Have you seen the new mix help task in Elixir 1.19?

Interesting, I wasn’t aware.

I still see some value in exh, people working in older versions can still browse docs on the terminal. Maybe I’ll add an option to just cache an index to use fzf with mix help in the future

Hey! Make sure to pull and install the new changes:

exh fetch now updates stale cache. A module is considered stale when the documentation found in the current project/context is different than the documentation for the same module stored in cache.

exh fetch --prune will delete modules that are not found in the current context.

CRUD statistics on cached modules will be displayed after caching.

Bug fix: exh will correctly display functions and macros that have no @spec