gcampes
Back

Bringing Import Cost to Zed by Faking Inline Decorations

Apr 13, 2026 04/13/2026

zedextensionslsptypescriptrustesbuildimport-cost

The extension I missed

If you've used VS Code, you've probably seen Import Cost by Wix. It shows the bundle size of your imports right next to the import line:

import { debounce } from 'lodash';  // 42.1kB (gzip: 14.2kB)

Over 5 million installs. One of those tools you don't think about until it's gone.

I recently switched to Zed as my daily editor. When I went looking for Import Cost in the extension marketplace, it didn't exist. Wix solved this for VS Code years ago. I wanted the same thing in Zed, which turned out to require a completely different approach.


The wall: Zed extensions can't draw on the screen

Zed's extension API is deliberately minimal. Extensions can add language support and run language servers, but they can't modify how the editor renders a file. There's no way to say "put this text at line 5." I dug through Zed's source code and found a TODO comment acknowledging the gap.

In VS Code, this is trivial. Extensions have full access to the editor's UI through a Decorations API. In Zed, it's impossible.

Or so it seemed.


The hack: repurposing inlay hints

Inlay hints, the gray labels editors use to show inferred types or parameter names, are a standard LSP (Language Server Protocol) feature. LSP is a protocol that lets editors talk to a separate process that knows about your code. The editor asks questions ("what hints should I show?"), and the server responds with labels positioned at specific locations in the file.

Here's the thing: nothing in the spec says hints have to be about types.

So what if I built a server that, instead of returning type information, returns bundle sizes? The editor asks for hints, and I respond with "put '42.1kB (gzip: 14.2kB)' at the end of line 3."

Zed asks for hints, I return bundle sizes. It thinks it's showing type information.


Architecture: a Rust launcher and a Node.js brain

The extension has two parts:

Part 1: A tiny Rust wrapper. Zed extensions must be written in Rust and compiled to WASM. Mine just downloads a Node.js server from npm at install time and tells Zed to start it. Here's the config:

[language_servers.import-cost-lsp]
name = "Import Cost"
languages = ["TypeScript", "TSX", "JavaScript", "JSX"]

That tells Zed: "for any TypeScript or JavaScript file, also start up this language server."

Part 2: A Node.js server. When Zed asks for inlay hints, here's the flow:

  1. Zed asks: "What hints should I show for lines 1-50 of this file?"
  2. The server parses the file to find all import statements
  3. For each import, it bundles with esbuild and measures the output size
  4. It sends back the sizes as inlay hints positioned at the end of each import line
  5. Zed renders them inline and the user sees the sizes right in their code

Results are cached by package name + imported symbols, so import { debounce } and import { debounce, throttle } from lodash are tracked separately. 500 entries, 5-minute TTL. After the first calculation, subsequent requests return instantly.


Parsing: finding the imports

JavaScript imports come in a lot of flavors:

import { debounce } from 'lodash';           // named import
import React from 'react';                    // default import
import * as path from 'path';                 // namespace import
import 'side-effects-only';                   // side-effect import
const express = require('express');            // CommonJS
const { readFile } = require('fs/promises');   // destructured require

I use the TypeScript compiler's parser to read the file rather than regex. It already understands every import flavor, so I walk through the syntax tree and extract what's needed: the package name, what's being imported, and the exact line position.

The server skips things that don't make sense to measure: relative imports (your own code), type-only imports (disappear at build time), and Node.js builtins.


Calculating sizes: the re-export trick

To know how big import { debounce } from 'lodash' would be in your final bundle, I actually bundle it. Minify it (strip whitespace, shorten variable names), tree-shake it (remove code you're not using), and measure the result.

The original VS Code extension uses webpack for this. I went with esbuild, which is dramatically faster.

For each import, I generate a tiny snippet and feed it directly into esbuild's in-memory API:

const result = await esbuild.build({
  stdin: {
    contents: `export { debounce } from 'lodash';`,
    resolveDir: projectRoot,
  },
  bundle: true,
  minify: true,
  treeShaking: true,
  write: false,        // keep output in memory
  platform: 'node',
});
 
const raw = result.outputFiles[0].contents.byteLength;
const gzipped = gzipSync(result.outputFiles[0].contents).byteLength;

Notice it's export { debounce }, not import { debounce }. If I imported without using, the bundler would tree-shake it away and report 0 bytes. By re-exporting, I'm telling esbuild "this code is needed", so the size accurately reflects what you'd ship. Another hack in the stack.

The write: false flag is key. esbuild returns the bundled output as a byte array instead of writing to disk. No temp files, no cleanup.


Gotchas

A silent config bug that broke everything

esbuild needs to know what platform you're targeting. I set platform: "browser" since I'm measuring what gets shipped to a browser.

Turns out, a lot of packages have Node.js dependencies. In browser mode, esbuild tries to polyfill or skip them, which produces 0 bytes or completely wrong sizes. Silently. No error, just wrong numbers. I spent too long debugging this.

Switching to platform: "node" fixed everything. This might seem wrong, but the platform flag mainly affects module resolution: which entry point esbuild picks from a package's package.json and whether it tries to polyfill Node builtins. The parts that determine size (minification and tree-shaking) work the same regardless of platform.

Users install the extension and see nothing

The inlay hints I'm repurposing are turned off by default in Zed. There's no way for an extension to change editor settings programmatically.

So after installing, users see... nothing. Until they go into their settings and enable inlay hints. I documented this prominently in the README, but it's a rough first experience. There's an open feature request for letting extensions declare default settings. I can't wait for that to land so the extension just works out of the box.


Testing

Shipping something that runs inside someone else's editor is high-stakes. If it breaks, you're messing with their workflow.

I wrote tests so I could ship updates without anxiety. A vitest suite covering the main paths: does the parser find the right imports across all the different flavors? Does the bundler produce reasonable sizes for real packages? Does the cache actually expire entries? Not exhaustive, but enough that I have a safety net for regression bugs.


Try it, and what's next

Install from the Zed extension marketplace. Search for "Import Cost" or install via the command palette. You'll need to enable inlay hints in your settings (⌘ + ,):

{
  "inlay_hints": {
    "enabled": true,
    "show_other_hints": true
  }
}

Open any .ts, .tsx, .js, or .jsx file and you should see bundle sizes appear next to your imports.

There are a few things I'd like to improve. A persistent cache that survives editor restarts. And eventually, color-coded hints based on size, once Zed supports styled inlay hints.

The source is on GitHub: gcampes/zed-import-cost. The server is on npm as import-cost-server. PRs are welcome.

This was built with heavy AI assistance. I used Claude through OpenCode for the Rust, TypeScript, and test code. That's how I build things now.

Thanks to Wix for the original Import Cost. The approach is completely different, but the idea is theirs.