Zed Extension API: Rust WASM으로 커스텀 LSP와 슬래시 커맨드를 에디터에 심는 법
에디터 확장 개발을 처음 들여다봤을 때 가장 먼저 느낀 건 "왜 이렇게 복잡하지?"였습니다. VS Code 익스텐션은 Node.js 런타임 위에서 TypeScript로 돌아가는데, 그러다 보니 성능 문제나 프로세스 격리 한계에 부딪히는 경우가 많았거든요. Zed는 이 문제를 아예 다른 방식으로 풀었습니다. 모든 익스텐션을 Rust로 작성해 WebAssembly 모듈로 컴파일하고, 샌드박스화된 WASM 런타임 위에서 실행합니다. 익스텐션 하나가 에디터 프로세스를 크래시로 끌고 내려가는 상황이 구조적으로 차단되는 거죠(무한 루프나 과도한 CPU 사용으로 인한 응답성 저하까지 막아주는 건 아닙니다).
2026년 기준으로 Zed의 Extension API는 꽤 성숙 단계에 접어들었습니다. 커스텀 언어 서버(LSP) 연동, AI 어시스턴트 패널용 슬래시 커맨드, MCP(Model Context Protocol) 서버 선언, DAP 기반 디버거 어댑터까지 익스텐션으로 제공할 수 있습니다. 이 글에서는 LSP 연동과 슬래시 커맨드 두 가지를 중심으로, 실제 코드와 함께 어떻게 동작하는지 들여다봅니다.
WASM 위에서 에디터와 대화한다는 게 무슨 의미인가
WIT: 익스텐션과 에디터 사이의 계약서
Zed 익스텐션의 아키텍처를 처음 봤을 때 가장 낯설었던 부분이 WIT(WebAssembly Interface Type) 입니다. WASM Component Model에 특화된 IDL로, protobuf처럼 value 타입과 리소스 타입, 인터페이스 상속을 표현할 수 있게 설계되었습니다. C 헤더처럼 링킹 시점에 밀리는 것도 아니고, OpenAPI처럼 REST/JSON에만 묶여 있는 것도 아닙니다. WASM 모듈과 호스트(에디터) 사이에 주고받을 타입과 함수를 인터페이스 단위로 못박아 놓는 언어라고 보면 됩니다.
Zed는 버전화된 WIT 파일로 Extension API를 관리하고, wit-bindgen이 여기서 Rust 바인딩을 자동 생성합니다. 개발자 입장에서는 이 바인딩을 통해 노출된 zed::Extension 트레이트를 구현하면 됩니다.
이 구조 덕분에 익스텐션-에디터 간 계약이 컴파일 타임에 검증됩니다. 엉뚱한 타입을 넘기거나 없는 함수를 호출하면 빌드 단계에서 바로 잡힙니다.
컴파일 타깃: wasip2로의 전환
솔직히 이 부분에서 삽질을 좀 했습니다. zed_extension_api 0.5 이상부터는 wasm32-wasip2 타깃이 필수입니다. 구버전 wasm32-wasip1으로 컴파일하면 __wasi_init_tp 관련 오류가 발생한다는 게 실제 이슈로도 보고되어 있습니다(#48724). wasip2는 WASI Preview 2, 즉 WebAssembly Component Model을 기반으로 하며 더 정교한 모듈 간 인터페이스를 제공합니다. Component Model이 아직 W3C에서 표준화 진행 중이라는 점을 감안하면, Zed가 상대적으로 이른 시점에 이 방향을 선택한 셈입니다.
타깃 설치는 간단합니다.
rustup target add wasm32-wasip2프로젝트 구조부터 잡기
익스텐션 하나의 기본 파일 구조는 이렇습니다.
my-extension/
├── extension.toml # 메타데이터, LSP·슬래시 커맨드 선언
├── Cargo.toml
├── src/
│ └── lib.rs # Extension 트레이트 구현
└── languages/
└── my-lang/
├── config.toml # 언어 정의
└── highlights.scm # Tree-sitter 구문 강조 쿼리Cargo.toml에서 중요한 것은 크레이트 타입을 cdylib으로 지정하고 zed_extension_api를 의존성에 추가하는 것입니다.
[lib]
crate-type = ["cdylib"]
[dependencies]
zed_extension_api = "0.5"extension.toml은 에디터가 이 익스텐션을 어떻게 취급할지 선언하는 파일입니다. 언어 서버와 슬래시 커맨드를 동시에 제공한다면 이렇게 됩니다.
id = "my-extension"
name = "My Extension"
version = "0.1.0"
schema_version = 1
authors = ["Your Name <you@example.com>"]
description = "Custom LSP and slash commands"
[language_servers.my-lsp]
name = "My Language Server"
language = "my-lang"
[[slash_commands]]
name = "my-command"
description = "Does something useful"
requires_argument = false커스텀 언어 서버 연동
language_server_command 구현
LSP 연동의 핵심은 language_server_command 메서드입니다. 이 메서드가 반환하는 커맨드로 Zed가 외부 LSP 프로세스를 실행합니다. LSP 실행 파일 자체는 WASM 바깥, 즉 사용자 시스템의 네이티브 환경에서 동작한다는 점이 중요합니다. WASM은 "어떻게 실행할지"를 알려주는 역할만 합니다.
아래 코드는 개념적 예시입니다. 트레이트 시그니처(&mut self, &self)와 필드명, 반환 타입은 zed_extension_api 버전에 따라 다를 수 있으니, 실제 구현 전 docs.rs 문서에서 정확한 시그니처를 확인하시는 걸 권장합니다.
// 개념적 예시. 실제 트레이트 시그니처는 zed_extension_api 버전별 docs.rs 참조.
use zed_extension_api::{self as zed, LanguageServerId, Worktree, Command, Result};
struct MyExtension;
impl zed::Extension for MyExtension {
fn new() -> Self {
MyExtension
}
fn language_server_command(
&mut self,
_language_server_id: &LanguageServerId,
worktree: &Worktree,
) -> Result<Command> {
let binary_path = worktree
.which("my-lsp")
.ok_or("my-lsp binary not found in PATH")?;
Ok(Command {
command: binary_path,
args: vec!["--stdio".to_string()],
env: vec![],
})
}
}
zed::register_extension!(MyExtension);트레이트 메서드 중 일부(예: language_server_command)가 &mut self이고 슬래시 커맨드 관련 메서드는 &self인 것은 WIT에서 각 함수가 요구하는 상태 접근 권한이 다르기 때문입니다. 실제 시그니처는 wit-bindgen이 생성한 코드가 기준이므로, 최신 API에서 어떤 조합인지 반드시 확인해야 합니다.
BAML 팀이 자체 DSL을 위한 Zed 익스텐션을 만든 사례에서도 동일한 접근을 씁니다. 워크트리에서 실행 파일 경로를 찾아 Command로 반환하고, Tree-sitter 문법으로 구문 강조를 붙이는 방식이죠.
LSP 바이너리를 직접 다운로드하는 패턴
사용자가 LSP를 별도로 설치하게 할 수도 있지만, 익스텐션이 직접 다운로드 로직을 구현하는 패턴도 많이 쓰입니다. Zed Extension API는 GitHub 릴리스에서 파일을 내려받는 헬퍼를 제공합니다.
// 개념적 예시(pseudocode에 가깝습니다).
// 함수명·필드명·enum variant는 zed_extension_api 버전에 따라 다르므로
// docs.rs에서 실제 시그니처와 사용 가능한 옵션을 확인하세요.
fn language_server_command(
&mut self,
_language_server_id: &LanguageServerId,
_worktree: &Worktree,
) -> Result<Command> {
// 필드명은 예: require_assets / requires_assets, pre_release / prerelease
// 등 버전 간 차이가 있을 수 있음
let release = zed::latest_github_release(
"my-org/my-lsp",
zed::GithubReleaseOptions { /* docs.rs 참조 */ },
)?;
let platform = zed::current_platform();
let asset_name = format!("my-lsp-{}-{}.tar.gz", release.version, platform);
let asset = release
.assets
.iter()
.find(|a| a.name == asset_name)
.ok_or_else(|| format!("no asset for platform: {platform}"))?;
// 다운로드 대상 경로는 익스텐션 작업 디렉터리 기준 상대 경로로 지정합니다.
// 호스트(Zed)는 이 경로를 익스텐션별 격리된 디렉터리로 매핑해 실제 파일
// 시스템 경로를 프로세스 exec 시점에 resolve합니다. 즉 WASM 안에서 만든
// 상대 경로가 그대로 native 커맨드로 넘어가는 것이 아니라, 호스트가
// 자기 저장 위치를 붙여 실행합니다. 정확한 매핑 규칙은 공식 문서 확인.
let binary_path = format!("my-lsp-{}/bin/my-lsp", release.version);
if !std::fs::metadata(&binary_path).map_or(false, |m| m.is_file()) {
zed::download_file(
&asset.download_url,
&binary_path,
zed::DownloadedFileType::GzipTar,
)
.map_err(|e| format!("failed to download: {e}"))?;
}
Ok(Command {
command: binary_path,
args: vec!["--stdio".to_string()],
env: vec![],
})
}슬래시 커맨드 구현
슬래시 커맨드는 Zed AI 어시스턴트 패널에서 /command-name 형태로 호출되는 커스텀 명령입니다. run_slash_command로 실행 동작을, complete_slash_command_argument로 자동완성을 정의합니다.
기본 구조
아래 코드 역시 개념적 예시입니다. 특히 SlashCommandOutputSection의 실제 필드 구성(예: icon 필드나 아이콘 enum variant의 존재 여부)은 버전별로 차이가 있으니, 컴파일 전에 SlashCommandOutputSection 문서에서 현재 필드를 반드시 확인하세요.
// 개념적 예시. SlashCommandOutputSection의 실제 필드 구성은
// docs.rs에서 확인 후 조정 필요.
use zed_extension_api::{
self as zed, SlashCommand, SlashCommandOutput, SlashCommandOutputSection,
Worktree, Result,
};
impl zed::Extension for MyExtension {
// ... new, language_server_command 등
fn run_slash_command(
&self,
command: SlashCommand,
args: Vec<String>,
_worktree: Option<&Worktree>,
) -> Result<SlashCommandOutput, String> {
match command.name.as_str() {
"my-command" => {
let label = args.first().cloned().unwrap_or_default();
let text = format!("# {label}\n\n여기에 컨텍스트 내용이 들어갑니다.");
Ok(SlashCommandOutput {
sections: vec![SlashCommandOutputSection {
range: (0..label.len()).into(),
label: label.clone(),
// 아이콘 관련 필드는 API 버전에 따라 존재/이름/타입이
// 다르므로 docs.rs를 확인하고 필요한 필드만 채우세요.
}],
text,
})
}
_ => Err(format!("unknown command: {}", command.name)),
}
}
fn complete_slash_command_argument(
&self,
command: SlashCommand,
_args: Vec<String>,
) -> Result<Vec<zed::SlashCommandArgumentCompletion>, String> {
match command.name.as_str() {
"my-command" => Ok(vec![
zed::SlashCommandArgumentCompletion {
label: "option-one".to_string(),
new_text: "option-one".to_string(),
run_command: true,
},
zed::SlashCommandArgumentCompletion {
label: "option-two".to_string(),
new_text: "option-two".to_string(),
run_command: true,
},
]),
_ => Err(format!("unknown command: {}", command.name)),
}
}
}Zed 공식 저장소의 slash-commands-example에는 echo(입력을 그대로 반환)와 pick-one(자동완성 포함 선택지 제공) 두 커맨드가 레퍼런스 구현으로 포함되어 있습니다. 처음 시작할 때 이 코드를 직접 읽어보는 게 가장 빠릅니다.
외부 API를 호출하는 패턴
JIRA 이슈를 AI 컨텍스트에 삽입하는 /jira 커맨드처럼 외부 API를 호출하는 패턴도 있습니다. 다만 WASM 환경에서의 네트워크 접근은 호스트가 허용하는 범위 내에서만 가능하며, 이 부분은 Extension API가 제공하는 HTTP 클라이언트를 사용하게 됩니다. 실제 구현 시에는 공식 문서에서 허용된 네트워크 접근 방법과 제한을 확인하시는 게 좋습니다.
빌드와 로컬 테스트
빌드는 단순합니다.
cargo build --release --target wasm32-wasip2로컬에서 테스트할 때는 Zed 설정 파일(~/.config/zed/settings.json)에 익스텐션 경로를 직접 등록할 수 있습니다.
{
"dev_extensions": ["/path/to/my-extension"]
}배포용 패키징은 extension.toml + 컴파일된 WASM + 선택적 Tree-sitter 문법 파일을 묶어 제출하는 방식입니다. 구체적인 패키지 형식과 제출 절차는 Developing Extensions 문서를 참조하세요.
Tree-sitter 파서를 포함하는 경우에는 C/C++ 기반 파서를 WASM으로 크로스컴파일해야 하므로 wasi-sdk가 추가로 필요합니다. Zed가 자동으로 다운로드하거나 WASI_SDK_PATH 환경변수로 경로를 지정할 수 있습니다.
트레이드오프: 어디서 막히는지 알고 시작하기
| 항목 | 강점 | 현실적 한계 |
|---|---|---|
| 보안/격리 | WASM 샌드박스 덕분에 익스텐션 크래시가 에디터로 전파되지 않음 | WASM 모듈 내부 디버깅 도구가 아직 제한적. 실무에서는 로깅과 Zed 익스텐션 오류 패널이 가장 현실적인 수단 |
| 타입 안전성 | WIT + Rust 조합으로 계약이 컴파일 타임 검증됨 | WIT API 변경 시 바인딩 재생성 필요 |
| 크로스 플랫폼 | WASM 하나로 macOS/Linux/Windows 모두 대응 | LSP 실행 파일은 별도로 네이티브 바이너리 필요 |
| API 표면 | 안정적인 핵심 API 제공 | VS Code Extension API 대비 접근 가능한 에디터 기능이 협소. UI 조작 API는 로드맵상 확장 예정 |
| 생태계 | WIT 기반 설계로 미래 확장 여지 있음 | 초기 생태계 단계로, 특정 언어·툴체인 공백 존재 |
| 빌드 복잡도 | 빌드 명령 자체는 단순 | wasm32-wasip2 타깃 설정, wasi-sdk 설치(Tree-sitter 포함 시), 빌드 파이프라인 구성 필요 |
자주 만나는 함정
wasip1 → wasip2 마이그레이션: 기존에 wasm32-wasip1으로 빌드하던 익스텐션을 유지보수한다면 타깃 변경과 API 호환성 확인이 둘 다 필요합니다. __wasi_init_tp 오류가 보인다면 타깃부터 확인해 보세요.
LSP 바이너리 배포 책임: WASM은 "어떻게 실행할지"만 알려줄 뿐, 실제 LSP 프로세스는 사용자 시스템에서 네이티브로 실행됩니다. 즉, 사용자 시스템에 LSP 바이너리가 있어야 합니다. 익스텐션이 다운로드 로직을 구현하지 않는다면, README에 설치 방법을 명확히 안내해야 합니다.
라이선스 파일: 익스텐션 배포 시 라이선스 파일이 필요합니다. 정확한 요건은 매번 Zed 공식 기여 가이드와 익스텐션 문서에서 확인하는 것이 안전합니다. 저장소 준비 단계에서 빠뜨리기 쉬운 항목입니다.
Zed Extension API가 지금 흥미로운 이유
Zed는 내장 디버거를 DAP 기반으로 구축했고, 익스텐션이 DAP 어댑터를 제공할 수 있는 API를 열어두었습니다(정확한 릴리스 시점과 세부 내용은 Zed 공식 블로그의 관련 포스트를 참고하세요). MCP 서버를 extension.toml에 선언하면 Zed의 AI 기능과 바로 연동되는 것도 흥미롭습니다. 공식 로드맵에는 익스텐션이 Zed UI 자체를 커스터마이징할 수 있는 API 확장 계획도 포함되어 있어, 지금 익스텐션 개발 경험을 쌓아두면 이후 더 넓은 가능성을 빠르게 활용할 수 있습니다.
현재 익스텐션으로 확장 가능한 지점과 앞으로 열릴 영역을 정리하면 다음과 같습니다.
- 현재 안정적으로 사용 가능: LSP 연동, 슬래시 커맨드, Tree-sitter 기반 문법 지원
- 비교적 최근 추가: MCP 서버 선언(AI 어시스턴트 연동), DAP 디버거 어댑터
- 로드맵상 확장 예고: UI 커스터마이징 API
Rust를 쓴다면 빌드 파이프라인의 진입 비용은 생각보다 낮습니다. Cargo.toml에 zed_extension_api를 추가하고, wasm32-wasip2 타깃을 설치하고, Extension 트레이트를 구현하면 기본 뼈대는 완성입니다. 특정 언어나 도구에 대한 Zed 지원이 아직 없다면, 직접 만들어서 생태계에 기여해볼 수 있는 공간이 충분히 남아있습니다.
시작할 때 참고하기 좋은 순서
처음 손대신다면, 이렇게 접근하는 걸 추천합니다. 공식 slash-commands-example을 그대로 클론해서 dev_extensions로 붙여보고 → 트레이트 시그니처와 리턴 타입을 docs.rs에서 하나씩 대조하며 자기 도메인용 커맨드로 바꿔보고 → 그다음 LSP 연동으로 넘어가면 API 표면을 자연스럽게 익힐 수 있습니다. 문서와 실제 API 시그니처가 어긋나는 지점이 종종 있으니, 항상 최신 zed_extension_api docs.rs를 기준점으로 삼으세요.
참고 자료
- Life of a Zed Extension: Rust, WIT, Wasm — Zed 공식 블로그
- Developing Extensions — Zed 공식 문서
- Slash Command Extensions — Zed 공식 문서
- Language Extensions — Zed 공식 문서
- slash-commands-example — GitHub (zed-industries/zed)
- SlashCommand in zed_extension_api — docs.rs
- zed_extension_api — lib.rs
- How to write a Zed extension for a made up language — BAML Blog
- Extensions System — DeepWiki (zed-industries/zed)
- wasm32-wasip2 컴파일 오류 이슈 #48724 — GitHub