Making Vim understand TypeScript path mapping
Vim has this well-known feature of opening the path under cursor with :h gf
. This article
explains well how this
works.
Things work out of the box with full paths with no special characters, like
/home/phelipe/script.js
, but it also expands ~
and environment variables,
like $HOME/script.js
or ~/script.js
.
Things don’t work well when languages have special syntax to import a file, which is most languages. For example, Java:
import foo.bar
Or Python:
import moduleimport .moduleimport ..module
But it’s possible to configure Vim to understand this special syntax with the
:h includeexpr
option, which is a function that receives the filename in the special variable
v:fname
so you can manipulate it.
For Java, such a function could just replace .
with /
, but for Python it’s
more
complicated.
In the JavaScript world this is even more complex… We have webpack aliases, Jest’s moduleNameMapper and much more.
The TypeScript compiler has this feature as well, it’s called path mapping.
For example, to import a component from src/components
from anywhere with
just ~/components
, you’d use the following tsconfig.json
:
{ "compilerOptions": { "baseUrl": ".", "paths": { "~/*": ["src/*"] } }}
This is great but it breaks gf
, so in this post I want to share how to make
it work again for TypeScript projects that use this feature.
Why not LSP?
You might be wondering why not just use a language server for that?
And you’d be right, that’s certainly better than implementing the TypeScript module resolution algorithm in Lua…
But the TypeScript language servers are very resource hungry and slow to boot, so I find that it does pay off to use a dumber way to navigate files with built-in Vim features in case the language server is still booting or just being slow.
Implementation details
Our goal is to pass a function to the includeexpr
option that will try to
substitute the “alias” (~/*
) with its associated path (src/*
) until it
finds a file/directory that exists, then return it. Otherwise, return nil
.
I decided to write it in Lua in the lua/tsconfig.lua
module. Here’s how we
can configure the includeexpr
for JavaScript/TypeScript to use this lua
function:
" after/ftplugin/javascript.vim" after/ftplugin/typescript.vim " It's common in JavaScript to omit the file extension" Also some plugins mess this up so I overwite it...setlocal suffixesadd=.js,.jsx,.ts,.tsx,.d.ts if has("nvim") setlocal includeexpr=luaeval(\"require'tsconfig'.includeexpr(_A)\",v:fname)endif
And here’s an skeleton of the Lua module:
-- lua/tsconfig.lua local M = {} local function expand_tsconfig_path(input) local tsconfig_file = get_tsconfig_file() if not tsconfig_file then return input end local alias_to_paths = get_tsconfig_paths(tsconfig_file) if not alias_to_paths then return input end for alias, path in pairs(alias_to_paths) do -- TODO: work to find a file that exists end return inputend function M.includeeexpr(input) local path = expand_tsconfig_path(input) return pathend return M
How exactly we try to find a file will be explained later.
Finding tsconfig.json
Let’s start with the get_tsconfig_file
function. We can do this by searching
upwards starting from the current file’s directory with the :h findfile
function:
findfile('tsconfig.json', '.;')
.
local function get_tsconfig_file() return find_file("tsconfig.json", ".;") or find_file("jsconfig.json", ".;")end
You’ll notice the find_file
usage, which is just a wrapper around findfile
that returns nil
if it doesn’t find one (empty strings are not falsy in Lua):
local function find_file(fname, path) local found = vim.fn.findfile(fname, path or "") if found ~= "" then return found endend
I use a similar function called find_dir
that wraps :h finddir
.
Reading JSON with comments
We also need to read the tsconfig.json
file. Vim has a function to serialize
JSON, :h json_decode
,
which is great, except that tsconfig.json
is not strictly JSON, since it
allows comments. No problem, we can just remove them before we pass it to
json_decode
:
local function remove_comments(line) return line:gsub("/%*.*%*/", ""):gsub("//.*", "")end local function decode_json_with_comments(fname) local json_without_comments = vim.tbl_map(remove_comments, vim.fn.readfile(fname)) return vim.fn.json_decode(json_without_comments)end
Parsing compilerOptions.paths
Next step is to parse compilerOptions.paths
to create a table that maps
aliases into their full paths. For example, given this configuration:
{ "compilerOptions": { "baseUrl": ".", "paths": { "~/*": ["src/*"] } }}
We want something like this:
{ "~/*": { get_full_path(base_url) .. "src/" }}
This is because paths are relative to compilerOptions.baseUrl
. Also, we
should remove the catch-all character *
:
local function get_dir(fname) return vim.fn.fnamemodify(fname, ":h")end local function get_tsconfig_paths(tsconfig_fname) if not tsconfig_fname then return {} end local json = decode_json_with_comments(tsconfig_fname) local base_url = json and json.compilerOptions and json.compilerOptions.baseUrl local alias-to_paths = {} if json and json.compilerOptions and json.compilerOptions.paths then for alias, paths in pairs(json.compilerOptions.paths) do alias_to_paths[alias] = vim.tbl_map( function(path) return vim.fn.simplify(get_dir(tsconfig_fname) .. "/" .. base_url .. "/" .. path:gsub("*", "")) end, paths ) end end return alias_to_pathsend
Expand tsconfig
Now let’s remove that TODO we left earlier.
We just need to “expand” (replace) the alias with its path until we find a file that exist.
First, we check if the input filename matches the alias (e.g., ~/file
should
match ~/*
but also *
since it means any value), replace the alias with its
path (~/file
-> src/file
) and try to find it:
local function expand_tsconfig_path(input) -- ... for alias, paths in pairs(alias_to_paths) do if alias == "*" or vim.startswith(input, alias:gsub("*", "")) then for _, path in pairs(paths) do local expanded_path = input:gsub(alias, path) local real_path = find_file(expanded_path) or find_dir(expanded_path) if real_path then return real_path end end end end return inputend
Handling configuration inheritance
One problem though… We’re ignoring TS Config’s
extends
option, which
allows you to inherit from other configuration files.
If a tsconfig.json
inherits from another configuration, our algorithm as it
is now just ignores these other configurations completely.
To handle this, we’ll need to recursively call get_tsconfig_paths
for every
tsconfig.json
that has an extends
option, until it doesn’t:
local function find_tsconfig_extends(extends, tsconfig_fname) if not extends or vim.startswith(extends, "@") then return end local tsconfig_dir = get_dir(tsconfig_fname) return vim.fn.simplify(tsconfig_dir .. "/" .. extends)end local function get_tsconfig_paths(tsconfig_fname, prev_base_url) if not tsconfig_fname then return {} end local json = decode_json_with_comments(tsconfig_fname) local base_url = json and json.compilerOptions and json.compilerOptions.baseUrl or prev_base_url local alias_to_paths = {} -- ... local tsconfig_extends = find_tsconfig_extends(json.extends, get_dir(tsconfig_fname)) return vim.tbl_extend("force", alias_to_paths, get_tsconfig_paths(tsconfig_extends, base_url))end
Conclusion
You can check the full implementation here. Be aware that this will likely change.
As I said earlier, Vim’s built-in file navigation features are not the best tool for the job but it does help me when tsserver/coc.nvim/watchman are being slow, unreliable or making my computer fans go crazy.
Vim support for go-to-definition functionality does not stop at the
includeexpr
option, but
the API is cumbersome to use. I tried to get go-to-defintion working for
TypeScript by using :h include-search
but had a hard time and eventually gave
up (the v:fname
API is not enough for nested imports), but it’s nice to have
a working gf
at least… It’s also a good idea to check out what the
vim-apathy plugin does for
JavaScript
if you want to dive more deep into it.