Making Vim understand TypeScript path mapping - Phelipe Teles

Making Vim understand TypeScript path mapping

5 min.
View source code

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:

java
import foo.bar

Or Python:

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:

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:

vim
" 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
-- 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', '.;').

lua
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):

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:

lua
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:

json
{  "compilerOptions": {    "baseUrl": ".",    "paths": {      "~/*": ["src/*"]    }  }}

We want something like this:

lua
{  "~/*": {    get_full_path(base_url) .. "src/"  }}

This is because paths are relative to compilerOptions.baseUrl. Also, we should remove the catch-all character *:

lua
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:

lua
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:

lua
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.