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 module
import .module
import ..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 input
end
function M.includeeexpr(input)
local path = expand_tsconfig_path(input)
return path
end
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
end
end
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_paths
end
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 input
end
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.