From 51ebe0eb0136a4009c240740d0bb3b3a9b4c173f Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Fri, 8 Mar 2024 08:49:27 -0800 Subject: [PATCH] Allow wasm extensions to do arbitrary file I/O in their own directory to install language servers (#9043) This PR provides WASM extensions with write access to their own specific working directory under the Zed `extensions` dir. This directory is set as the extensions `current_dir` when they run. Extensions can return relative paths from the `Extension::language_server_command` method, and those relative paths will be interpreted relative to this working dir. With this functionality, most language servers that we currently build into zed can be installed using extensions. Release Notes: - N/A --- .github/workflows/ci.yml | 6 - Cargo.lock | 4 + Cargo.toml | 1 + crates/extension/Cargo.toml | 5 + crates/extension/src/build_extension.rs | 6 +- crates/extension/src/extension_lsp_adapter.rs | 19 +- crates/extension/src/extension_store.rs | 42 ++-- crates/extension/src/extension_store_test.rs | 229 ++++++++++++------ crates/extension/src/wasm_host.rs | 158 +++++++++--- crates/extension_api/src/extension_api.rs | 1 + crates/extensions_ui/src/extensions_ui.rs | 9 +- crates/project/src/project.rs | 7 +- extensions/gleam/src/gleam.rs | 149 +++++++----- 13 files changed, 421 insertions(+), 215 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9b1657f1d4..0691cba81d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -86,12 +86,6 @@ jobs: clean: false submodules: "recursive" - - name: Install cargo-component - run: | - if ! which cargo-component > /dev/null; then - cargo install cargo-component - fi - - name: cargo clippy run: cargo xtask clippy diff --git a/Cargo.lock b/Cargo.lock index d5f5377f05..fd549665ec 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3536,7 +3536,10 @@ dependencies = [ "async-compression", "async-tar", "async-trait", + "cap-std", "collections", + "ctor", + "env_logger", "fs", "futures 0.3.28", "gpui", @@ -3544,6 +3547,7 @@ dependencies = [ "log", "lsp", "node_runtime", + "parking_lot 0.11.2", "project", "schemars", "serde", diff --git a/Cargo.toml b/Cargo.toml index da8a628ab4..b6434ec832 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -201,6 +201,7 @@ bitflags = "2.4.2" blade-graphics = { git = "https://github.com/kvark/blade", rev = "43721bf42d298b7cbee2195ee66f73a5f1c7b2fc" } blade-macros = { git = "https://github.com/kvark/blade", rev = "43721bf42d298b7cbee2195ee66f73a5f1c7b2fc" } blade-rwh = { package = "raw-window-handle", version = "0.5" } +cap-std = "2.0" chrono = { version = "0.4", features = ["serde"] } clap = "4.4" clickhouse = { version = "0.11.6" } diff --git a/crates/extension/Cargo.toml b/crates/extension/Cargo.toml index e5375f2cec..ceb1a0af3e 100644 --- a/crates/extension/Cargo.toml +++ b/crates/extension/Cargo.toml @@ -20,6 +20,7 @@ anyhow.workspace = true async-compression.workspace = true async-tar.workspace = true async-trait.workspace = true +cap-std.workspace = true collections.workspace = true fs.workspace = true futures.workspace = true @@ -42,6 +43,10 @@ wasmparser.workspace = true wit-component.workspace = true [dev-dependencies] +ctor.workspace = true +env_logger.workspace = true +parking_lot.workspace = true + fs = { workspace = true, features = ["test-support"] } gpui = { workspace = true, features = ["test-support"] } language = { workspace = true, features = ["test-support"] } diff --git a/crates/extension/src/build_extension.rs b/crates/extension/src/build_extension.rs index 877af96248..b7ff4512a7 100644 --- a/crates/extension/src/build_extension.rs +++ b/crates/extension/src/build_extension.rs @@ -317,7 +317,10 @@ impl ExtensionBuilder { fs::remove_file(&cache_path).ok(); - log::info!("downloading wasi adapter module"); + log::info!( + "downloading wasi adapter module to {}", + cache_path.display() + ); let mut response = self .http .get(WASI_ADAPTER_URL, AsyncBody::default(), true) @@ -357,6 +360,7 @@ impl ExtensionBuilder { fs::remove_dir_all(&wasi_sdk_dir).ok(); fs::remove_dir_all(&tar_out_dir).ok(); + log::info!("downloading wasi-sdk to {}", wasi_sdk_dir.display()); let mut response = self.http.get(&url, AsyncBody::default(), true).await?; let body = BufReader::new(response.body_mut()); let body = GzipDecoder::new(body); diff --git a/crates/extension/src/extension_lsp_adapter.rs b/crates/extension/src/extension_lsp_adapter.rs index a981facef0..41bfa9b29e 100644 --- a/crates/extension/src/extension_lsp_adapter.rs +++ b/crates/extension/src/extension_lsp_adapter.rs @@ -1,4 +1,4 @@ -use crate::wasm_host::{wit::LanguageServerConfig, WasmExtension}; +use crate::wasm_host::{wit::LanguageServerConfig, WasmExtension, WasmHost}; use anyhow::{anyhow, Result}; use async_trait::async_trait; use futures::{Future, FutureExt}; @@ -16,7 +16,7 @@ use wasmtime_wasi::preview2::WasiView as _; pub struct ExtensionLspAdapter { pub(crate) extension: WasmExtension, pub(crate) config: LanguageServerConfig, - pub(crate) work_dir: PathBuf, + pub(crate) host: Arc, } #[async_trait] @@ -41,18 +41,23 @@ impl LspAdapter for ExtensionLspAdapter { |extension, store| { async move { let resource = store.data_mut().table().push(delegate)?; - extension + let command = extension .call_language_server_command(store, &this.config, resource) - .await + .await? + .map_err(|e| anyhow!("{}", e))?; + anyhow::Ok(command) } .boxed() } }) - .await? - .map_err(|e| anyhow!("{}", e))?; + .await?; + + let path = self + .host + .path_from_extension(&self.extension.manifest.id, command.command.as_ref()); Ok(LanguageServerBinary { - path: self.work_dir.join(&command.command), + path, arguments: command.args.into_iter().map(|arg| arg.into()).collect(), env: Some(command.env.into_iter().collect()), }) diff --git a/crates/extension/src/extension_store.rs b/crates/extension/src/extension_store.rs index 01dffb653c..00f0dff8d8 100644 --- a/crates/extension/src/extension_store.rs +++ b/crates/extension/src/extension_store.rs @@ -100,6 +100,7 @@ enum ExtensionOperation { #[derive(Copy, Clone)] pub enum Event { ExtensionsUpdated, + StartedReloading, } impl EventEmitter for ExtensionStore {} @@ -148,6 +149,7 @@ pub fn init( let store = cx.new_model(move |cx| { ExtensionStore::new( EXTENSIONS_DIR.clone(), + None, fs, http_client, node_runtime, @@ -159,7 +161,7 @@ pub fn init( cx.on_action(|_: &ReloadExtensions, cx| { let store = cx.global::().0.clone(); - store.update(cx, |store, _| drop(store.reload(None))); + store.update(cx, |store, cx| drop(store.reload(None, cx))); }); cx.set_global(GlobalExtensionStore(store)); @@ -170,8 +172,10 @@ impl ExtensionStore { cx.global::().0.clone() } + #[allow(clippy::too_many_arguments)] pub fn new( extensions_dir: PathBuf, + build_dir: Option, fs: Arc, http_client: Arc, node_runtime: Arc, @@ -180,7 +184,7 @@ impl ExtensionStore { cx: &mut ModelContext, ) -> Self { let work_dir = extensions_dir.join("work"); - let build_dir = extensions_dir.join("build"); + let build_dir = build_dir.unwrap_or_else(|| extensions_dir.join("build")); let installed_dir = extensions_dir.join("installed"); let index_path = extensions_dir.join("index.json"); @@ -226,7 +230,7 @@ impl ExtensionStore { // it must be asynchronously rebuilt. let mut extension_index = ExtensionIndex::default(); let mut extension_index_needs_rebuild = true; - if let Some(index_content) = index_content.log_err() { + if let Some(index_content) = index_content.ok() { if let Some(index) = serde_json::from_str(&index_content).log_err() { extension_index = index; if let (Ok(Some(index_metadata)), Ok(Some(extensions_metadata))) = @@ -243,7 +247,7 @@ impl ExtensionStore { // index needs to be rebuild, then enqueue let load_initial_extensions = this.extensions_updated(extension_index, cx); if extension_index_needs_rebuild { - let _ = this.reload(None); + let _ = this.reload(None, cx); } // Perform all extension loading in a single task to ensure that we @@ -255,7 +259,7 @@ impl ExtensionStore { let mut debounce_timer = cx .background_executor() - .timer(RELOAD_DEBOUNCE_DURATION) + .spawn(futures::future::pending()) .fuse(); loop { select_biased! { @@ -271,7 +275,8 @@ impl ExtensionStore { this.update(&mut cx, |this, _| { this.modified_extensions.extend(extension_id); })?; - debounce_timer = cx.background_executor() + debounce_timer = cx + .background_executor() .timer(RELOAD_DEBOUNCE_DURATION) .fuse(); } @@ -313,12 +318,17 @@ impl ExtensionStore { this } - fn reload(&mut self, modified_extension: Option>) -> impl Future { + fn reload( + &mut self, + modified_extension: Option>, + cx: &mut ModelContext, + ) -> impl Future { let (tx, rx) = oneshot::channel(); self.reload_complete_senders.push(tx); self.reload_tx .unbounded_send(modified_extension) .expect("reload task exited"); + cx.emit(Event::StartedReloading); async move { rx.await.ok(); } @@ -444,7 +454,7 @@ impl ExtensionStore { archive .unpack(extensions_dir.join(extension_id.as_ref())) .await?; - this.update(&mut cx, |this, _| this.reload(Some(extension_id)))? + this.update(&mut cx, |this, cx| this.reload(Some(extension_id), cx))? .await; anyhow::Ok(()) }) @@ -483,7 +493,8 @@ impl ExtensionStore { ) .await?; - this.update(&mut cx, |this, _| this.reload(None))?.await; + this.update(&mut cx, |this, cx| this.reload(None, cx))? + .await; anyhow::Ok(()) }) .detach_and_log_err(cx) @@ -493,7 +504,7 @@ impl ExtensionStore { &mut self, extension_source_path: PathBuf, cx: &mut ModelContext, - ) { + ) -> Task> { let extensions_dir = self.extensions_dir(); let fs = self.fs.clone(); let builder = self.builder.clone(); @@ -560,11 +571,10 @@ impl ExtensionStore { fs.create_symlink(output_path, extension_source_path) .await?; - this.update(&mut cx, |this, _| this.reload(Some(extension_id)))? + this.update(&mut cx, |this, cx| this.reload(None, cx))? .await; Ok(()) }) - .detach_and_log_err(cx) } pub fn rebuild_dev_extension(&mut self, extension_id: Arc, cx: &mut ModelContext) { @@ -592,7 +602,7 @@ impl ExtensionStore { })?; if result.is_ok() { - this.update(&mut cx, |this, _| this.reload(Some(extension_id)))? + this.update(&mut cx, |this, cx| this.reload(Some(extension_id), cx))? .await; } @@ -664,9 +674,9 @@ impl ExtensionStore { log::info!( "extensions updated. loading {}, reloading {}, unloading {}", - extensions_to_unload.len() - reload_count, + extensions_to_load.len() - reload_count, reload_count, - extensions_to_load.len() - reload_count + extensions_to_unload.len() - reload_count ); let themes_to_remove = old_index @@ -839,7 +849,7 @@ impl ExtensionStore { language_server_config.language.clone(), Arc::new(ExtensionLspAdapter { extension: wasm_extension.clone(), - work_dir: this.wasm_host.work_dir.join(manifest.id.as_ref()), + host: this.wasm_host.clone(), config: wit::LanguageServerConfig { name: language_server_name.0.to_string(), language_name: language_server_config.language.to_string(), diff --git a/crates/extension/src/extension_store_test.rs b/crates/extension/src/extension_store_test.rs index 7ef2247883..09aee3f878 100644 --- a/crates/extension/src/extension_store_test.rs +++ b/crates/extension/src/extension_store_test.rs @@ -1,11 +1,10 @@ use crate::{ - build_extension::{CompileExtensionOptions, ExtensionBuilder}, ExtensionIndex, ExtensionIndexEntry, ExtensionIndexLanguageEntry, ExtensionIndexThemeEntry, ExtensionManifest, ExtensionStore, GrammarManifestEntry, RELOAD_DEBOUNCE_DURATION, }; use async_compression::futures::bufread::GzipEncoder; use collections::BTreeMap; -use fs::{FakeFs, Fs}; +use fs::{FakeFs, Fs, RealFs}; use futures::{io::BufReader, AsyncReadExt, StreamExt}; use gpui::{Context, TestAppContext}; use language::{ @@ -13,6 +12,7 @@ use language::{ LanguageServerName, }; use node_runtime::FakeNodeRuntime; +use parking_lot::Mutex; use project::Project; use serde_json::json; use settings::SettingsStore; @@ -22,7 +22,18 @@ use std::{ sync::Arc, }; use theme::ThemeRegistry; -use util::http::{self, FakeHttpClient, Response}; +use util::{ + http::{FakeHttpClient, Response}, + test::temp_tree, +}; + +#[cfg(test)] +#[ctor::ctor] +fn init_logger() { + if std::env::var("RUST_LOG").is_ok() { + env_logger::init(); + } +} #[gpui::test] async fn test_extension_store(cx: &mut TestAppContext) { @@ -248,6 +259,7 @@ async fn test_extension_store(cx: &mut TestAppContext) { let store = cx.new_model(|cx| { ExtensionStore::new( PathBuf::from("/the-extension-dir"), + None, fs.clone(), http_client.clone(), node_runtime.clone(), @@ -335,7 +347,7 @@ async fn test_extension_store(cx: &mut TestAppContext) { }, ); - let _ = store.update(cx, |store, _| store.reload(None)); + let _ = store.update(cx, |store, cx| store.reload(None, cx)); cx.executor().advance_clock(RELOAD_DEBOUNCE_DURATION); store.read_with(cx, |store, _| { @@ -365,6 +377,7 @@ async fn test_extension_store(cx: &mut TestAppContext) { let store = cx.new_model(|cx| { ExtensionStore::new( PathBuf::from("/the-extension-dir"), + None, fs.clone(), http_client.clone(), node_runtime.clone(), @@ -422,6 +435,7 @@ async fn test_extension_store(cx: &mut TestAppContext) { #[gpui::test] async fn test_extension_store_with_gleam_extension(cx: &mut TestAppContext) { init_test(cx); + cx.executor().allow_parking(); let root_dir = Path::new(env!("CARGO_MANIFEST_DIR")) .parent() @@ -431,32 +445,19 @@ async fn test_extension_store_with_gleam_extension(cx: &mut TestAppContext) { let cache_dir = root_dir.join("target"); let gleam_extension_dir = root_dir.join("extensions").join("gleam"); - cx.executor().allow_parking(); - ExtensionBuilder::new(cache_dir, http::client()) - .compile_extension( - &gleam_extension_dir, - CompileExtensionOptions { release: false }, - ) - .await - .unwrap(); - cx.executor().forbid_parking(); + let fs = Arc::new(RealFs); + let extensions_dir = temp_tree(json!({ + "installed": {}, + "work": {} + })); + let project_dir = temp_tree(json!({ + "test.gleam": "" + })); - let fs = FakeFs::new(cx.executor()); - fs.insert_tree("/the-extension-dir", json!({ "installed": {} })) - .await; - fs.insert_tree_from_real_fs("/the-extension-dir/installed/gleam", gleam_extension_dir) - .await; + let extensions_dir = extensions_dir.path().canonicalize().unwrap(); + let project_dir = project_dir.path().canonicalize().unwrap(); - fs.insert_tree( - "/the-project-dir", - json!({ - ".tool-versions": "rust 1.73.0", - "test.gleam": "" - }), - ) - .await; - - let project = Project::test(fs.clone(), ["/the-project-dir".as_ref()], cx).await; + let project = Project::test(fs.clone(), [project_dir.as_path()], cx).await; let language_registry = project.read_with(cx, |project, _cx| project.languages().clone()); let theme_registry = Arc::new(ThemeRegistry::new(Box::new(()))); @@ -464,55 +465,76 @@ async fn test_extension_store_with_gleam_extension(cx: &mut TestAppContext) { let mut status_updates = language_registry.language_server_binary_statuses(); - let http_client = FakeHttpClient::create({ - move |request| async move { - match request.uri().to_string().as_str() { - "https://api.github.com/repos/gleam-lang/gleam/releases" => Ok(Response::new( - json!([ - { - "tag_name": "v1.2.3", - "prerelease": false, - "tarball_url": "", - "zipball_url": "", - "assets": [ - { - "name": "gleam-v1.2.3-aarch64-apple-darwin.tar.gz", - "browser_download_url": "http://example.com/the-download" - } - ] - } - ]) - .to_string() - .into(), - )), + struct FakeLanguageServerVersion { + version: String, + binary_contents: String, + http_request_count: usize, + } - "http://example.com/the-download" => { + let language_server_version = Arc::new(Mutex::new(FakeLanguageServerVersion { + version: "v1.2.3".into(), + binary_contents: "the-binary-contents".into(), + http_request_count: 0, + })); + + let http_client = FakeHttpClient::create({ + let language_server_version = language_server_version.clone(); + move |request| { + let language_server_version = language_server_version.clone(); + async move { + language_server_version.lock().http_request_count += 1; + let version = language_server_version.lock().version.clone(); + let binary_contents = language_server_version.lock().binary_contents.clone(); + + let github_releases_uri = "https://api.github.com/repos/gleam-lang/gleam/releases"; + let asset_download_uri = + format!("https://fake-download.example.com/gleam-{version}"); + + let uri = request.uri().to_string(); + if uri == github_releases_uri { + Ok(Response::new( + json!([ + { + "tag_name": version, + "prerelease": false, + "tarball_url": "", + "zipball_url": "", + "assets": [ + { + "name": format!("gleam-{version}-aarch64-apple-darwin.tar.gz"), + "browser_download_url": asset_download_uri + } + ] + } + ]) + .to_string() + .into(), + )) + } else if uri == asset_download_uri { let mut bytes = Vec::::new(); let mut archive = async_tar::Builder::new(&mut bytes); let mut header = async_tar::Header::new_gnu(); - let content = "the-gleam-binary-contents".as_bytes(); - header.set_size(content.len() as u64); + header.set_size(binary_contents.len() as u64); archive - .append_data(&mut header, "gleam", content) + .append_data(&mut header, "gleam", binary_contents.as_bytes()) .await .unwrap(); archive.into_inner().await.unwrap(); - let mut gzipped_bytes = Vec::new(); let mut encoder = GzipEncoder::new(BufReader::new(bytes.as_slice())); encoder.read_to_end(&mut gzipped_bytes).await.unwrap(); - Ok(Response::new(gzipped_bytes.into())) + } else { + Ok(Response::builder().status(404).body("not found".into())?) } - - _ => Ok(Response::builder().status(404).body("not found".into())?), } } }); - let _store = cx.new_model(|cx| { + let extension_store = cx.new_model(|cx| { ExtensionStore::new( - PathBuf::from("/the-extension-dir"), + extensions_dir.clone(), + Some(cache_dir), fs.clone(), http_client.clone(), node_runtime, @@ -522,17 +544,35 @@ async fn test_extension_store_with_gleam_extension(cx: &mut TestAppContext) { ) }); - cx.executor().advance_clock(RELOAD_DEBOUNCE_DURATION); + // Ensure that debounces fire. + let mut events = cx.events(&extension_store); + let executor = cx.executor(); + let _task = cx.executor().spawn(async move { + while let Some(event) = events.next().await { + match event { + crate::Event::StartedReloading => { + executor.advance_clock(RELOAD_DEBOUNCE_DURATION); + } + _ => (), + } + } + }); + + extension_store + .update(cx, |store, cx| { + store.install_dev_extension(gleam_extension_dir.clone(), cx) + }) + .await + .unwrap(); let mut fake_servers = language_registry.fake_language_servers("Gleam"); let buffer = project .update(cx, |project, cx| { - project.open_local_buffer("/the-project-dir/test.gleam", cx) + project.open_local_buffer(project_dir.join("test.gleam"), cx) }) .await .unwrap(); - project.update(cx, |project, cx| { project.set_language_for_buffer( &buffer, @@ -548,20 +588,16 @@ async fn test_extension_store_with_gleam_extension(cx: &mut TestAppContext) { }); let fake_server = fake_servers.next().await.unwrap(); + let expected_server_path = extensions_dir.join("work/gleam/gleam-v1.2.3/gleam"); + let expected_binary_contents = language_server_version.lock().binary_contents.clone(); - assert_eq!( - fs.load("/the-extension-dir/work/gleam/gleam-v1.2.3/gleam".as_ref()) - .await - .unwrap(), - "the-gleam-binary-contents" - ); - - assert_eq!( - fake_server.binary.path, - PathBuf::from("/the-extension-dir/work/gleam/gleam-v1.2.3/gleam") - ); + assert_eq!(fake_server.binary.path, expected_server_path); assert_eq!(fake_server.binary.arguments, [OsString::from("lsp")]); - + assert_eq!( + fs.load(&expected_server_path).await.unwrap(), + expected_binary_contents + ); + assert_eq!(language_server_version.lock().http_request_count, 2); assert_eq!( [ status_updates.next().await.unwrap(), @@ -583,6 +619,51 @@ async fn test_extension_store_with_gleam_extension(cx: &mut TestAppContext) { ) ] ); + + // Simulate a new version of the language server being released + language_server_version.lock().version = "v2.0.0".into(); + language_server_version.lock().binary_contents = "the-new-binary-contents".into(); + language_server_version.lock().http_request_count = 0; + + // Start a new instance of the language server. + project.update(cx, |project, cx| { + project.restart_language_servers_for_buffers([buffer.clone()], cx) + }); + + // The extension has cached the binary path, and does not attempt + // to reinstall it. + let fake_server = fake_servers.next().await.unwrap(); + assert_eq!(fake_server.binary.path, expected_server_path); + assert_eq!( + fs.load(&expected_server_path).await.unwrap(), + expected_binary_contents + ); + assert_eq!(language_server_version.lock().http_request_count, 0); + + // Reload the extension, clearing its cache. + // Start a new instance of the language server. + extension_store + .update(cx, |store, cx| store.reload(Some("gleam".into()), cx)) + .await; + + cx.executor().run_until_parked(); + project.update(cx, |project, cx| { + project.restart_language_servers_for_buffers([buffer.clone()], cx) + }); + + // The extension re-fetches the latest version of the language server. + let fake_server = fake_servers.next().await.unwrap(); + let new_expected_server_path = extensions_dir.join("work/gleam/gleam-v2.0.0/gleam"); + let expected_binary_contents = language_server_version.lock().binary_contents.clone(); + assert_eq!(fake_server.binary.path, new_expected_server_path); + assert_eq!(fake_server.binary.arguments, [OsString::from("lsp")]); + assert_eq!( + fs.load(&new_expected_server_path).await.unwrap(), + expected_binary_contents + ); + + // The old language server directory has been cleaned up. + assert!(fs.metadata(&expected_server_path).await.unwrap().is_none()); } fn init_test(cx: &mut TestAppContext) { diff --git a/crates/extension/src/wasm_host.rs b/crates/extension/src/wasm_host.rs index 611cd9c9b0..84f2cf698a 100644 --- a/crates/extension/src/wasm_host.rs +++ b/crates/extension/src/wasm_host.rs @@ -5,7 +5,10 @@ use async_tar::Archive; use async_trait::async_trait; use fs::Fs; use futures::{ - channel::{mpsc::UnboundedSender, oneshot}, + channel::{ + mpsc::{self, UnboundedSender}, + oneshot, + }, future::BoxFuture, io::BufReader, Future, FutureExt, StreamExt as _, @@ -14,7 +17,8 @@ use gpui::BackgroundExecutor; use language::{LanguageRegistry, LanguageServerBinaryStatus, LspAdapterDelegate}; use node_runtime::NodeRuntime; use std::{ - path::PathBuf, + env, + path::{Path, PathBuf}, sync::{Arc, OnceLock}, }; use util::{http::HttpClient, SemanticVersion}; @@ -22,7 +26,7 @@ use wasmtime::{ component::{Component, Linker, Resource, ResourceTable}, Engine, Store, }; -use wasmtime_wasi::preview2::{command as wasi_command, WasiCtx, WasiCtxBuilder, WasiView}; +use wasmtime_wasi::preview2::{self as wasi, WasiCtx}; pub mod wit { wasmtime::component::bindgen!({ @@ -49,6 +53,7 @@ pub(crate) struct WasmHost { #[derive(Clone)] pub struct WasmExtension { tx: UnboundedSender, + pub(crate) manifest: Arc, #[allow(unused)] zed_api_version: SemanticVersion, } @@ -56,7 +61,7 @@ pub struct WasmExtension { pub(crate) struct WasmState { manifest: Arc, table: ResourceTable, - ctx: WasiCtx, + ctx: wasi::WasiCtx, host: Arc, } @@ -67,6 +72,8 @@ type ExtensionCall = Box< static WASM_ENGINE: OnceLock = OnceLock::new(); +const EXTENSION_WORK_DIR_PATH: &str = "/zed/work"; + impl WasmHost { pub fn new( fs: Arc, @@ -84,8 +91,8 @@ impl WasmHost { }) .clone(); let mut linker = Linker::new(&engine); - wasi_command::add_to_linker(&mut linker).unwrap(); - wit::Extension::add_to_linker(&mut linker, |state: &mut WasmState| state).unwrap(); + wasi::command::add_to_linker(&mut linker).unwrap(); + wit::Extension::add_to_linker(&mut linker, wasi_view).unwrap(); Arc::new(Self { engine, linker: Arc::new(linker), @@ -112,22 +119,14 @@ impl WasmHost { for part in wasmparser::Parser::new(0).parse_all(&wasm_bytes) { if let wasmparser::Payload::CustomSection(s) = part? { if s.name() == "zed:api-version" { - if s.data().len() != 6 { + zed_api_version = parse_extension_version(s.data()); + if zed_api_version.is_none() { bail!( "extension {} has invalid zed:api-version section: {:?}", manifest.id, s.data() ); } - - let major = u16::from_be_bytes(s.data()[0..2].try_into().unwrap()) as _; - let minor = u16::from_be_bytes(s.data()[2..4].try_into().unwrap()) as _; - let patch = u16::from_be_bytes(s.data()[4..6].try_into().unwrap()) as _; - zed_api_version = Some(SemanticVersion { - major, - minor, - patch, - }) } } } @@ -139,36 +138,95 @@ impl WasmHost { let mut store = wasmtime::Store::new( &this.engine, WasmState { - manifest, + ctx: this.build_wasi_ctx(&manifest).await?, + manifest: manifest.clone(), table: ResourceTable::new(), - ctx: WasiCtxBuilder::new() - .inherit_stdio() - .env("RUST_BACKTRACE", "1") - .build(), host: this.clone(), }, ); + let (mut extension, instance) = wit::Extension::instantiate_async(&mut store, &component, &this.linker) .await - .context("failed to instantiate wasm component")?; - let (tx, mut rx) = futures::channel::mpsc::unbounded::(); + .context("failed to instantiate wasm extension")?; + extension + .call_init_extension(&mut store) + .await + .context("failed to initialize wasm extension")?; + + let (tx, mut rx) = mpsc::unbounded::(); executor .spawn(async move { - extension.call_init_extension(&mut store).await.unwrap(); - let _instance = instance; while let Some(call) = rx.next().await { (call)(&mut extension, &mut store).await; } }) .detach(); + Ok(WasmExtension { + manifest, tx, zed_api_version, }) } } + + async fn build_wasi_ctx(&self, manifest: &Arc) -> Result { + use cap_std::{ambient_authority, fs::Dir}; + + let extension_work_dir = self.work_dir.join(manifest.id.as_ref()); + self.fs + .create_dir(&extension_work_dir) + .await + .context("failed to create extension work dir")?; + + let work_dir_preopen = Dir::open_ambient_dir(extension_work_dir, ambient_authority()) + .context("failed to preopen extension work directory")?; + let current_dir_preopen = work_dir_preopen + .try_clone() + .context("failed to preopen extension current directory")?; + + let perms = wasi::FilePerms::all(); + let dir_perms = wasi::DirPerms::all(); + + Ok(wasi::WasiCtxBuilder::new() + .inherit_stdio() + .preopened_dir(current_dir_preopen, dir_perms, perms, ".") + .preopened_dir(work_dir_preopen, dir_perms, perms, EXTENSION_WORK_DIR_PATH) + .env("PWD", EXTENSION_WORK_DIR_PATH) + .env("RUST_BACKTRACE", "1") + .build()) + } + + pub fn path_from_extension(&self, id: &Arc, path: &Path) -> PathBuf { + self.writeable_path_from_extension(id, path) + .unwrap_or_else(|| path.to_path_buf()) + } + + pub fn writeable_path_from_extension(&self, id: &Arc, path: &Path) -> Option { + let path = path.strip_prefix(EXTENSION_WORK_DIR_PATH).unwrap_or(path); + if path.is_relative() { + let mut result = self.work_dir.clone(); + result.push(id.as_ref()); + result.extend(path); + Some(result) + } else { + None + } + } +} + +fn parse_extension_version(data: &[u8]) -> Option { + if data.len() == 6 { + Some(SemanticVersion { + major: u16::from_be_bytes([data[0], data[1]]) as _, + minor: u16::from_be_bytes([data[2], data[3]]) as _, + patch: u16::from_be_bytes([data[4], data[5]]) as _, + }) + } else { + None + } } impl WasmExtension { @@ -194,6 +252,13 @@ impl WasmExtension { } } +impl WasmState { + pub fn writeable_path_from_extension(&self, path: &Path) -> Option { + self.host + .writeable_path_from_extension(&self.manifest.id, path) + } +} + #[async_trait] impl wit::HostWorktree for WasmState { async fn read_text_file( @@ -201,7 +266,7 @@ impl wit::HostWorktree for WasmState { delegate: Resource>, path: String, ) -> wasmtime::Result> { - let delegate = self.table().get(&delegate)?; + let delegate = self.table.get(&delegate)?; Ok(delegate .read_text_file(path.into()) .await @@ -269,13 +334,13 @@ impl wit::ExtensionImports for WasmState { async fn current_platform(&mut self) -> Result<(wit::Os, wit::Architecture)> { Ok(( - match std::env::consts::OS { + match env::consts::OS { "macos" => wit::Os::Mac, "linux" => wit::Os::Linux, "windows" => wit::Os::Windows, _ => panic!("unsupported os"), }, - match std::env::consts::ARCH { + match env::consts::ARCH { "aarch64" => wit::Architecture::Aarch64, "x86" => wit::Architecture::X86, "x86_64" => wit::Architecture::X8664, @@ -314,18 +379,24 @@ impl wit::ExtensionImports for WasmState { async fn download_file( &mut self, url: String, - filename: String, + path: String, file_type: wit::DownloadedFileType, ) -> wasmtime::Result> { + let path = PathBuf::from(path); + async fn inner( this: &mut WasmState, url: String, - filename: String, + path: PathBuf, file_type: wit::DownloadedFileType, ) -> anyhow::Result<()> { - this.host.fs.create_dir(&this.host.work_dir).await?; - let container_dir = this.host.work_dir.join(this.manifest.id.as_ref()); - let destination_path = container_dir.join(&filename); + let extension_work_dir = this.host.work_dir.join(this.manifest.id.as_ref()); + + this.host.fs.create_dir(&extension_work_dir).await?; + + let destination_path = this + .writeable_path_from_extension(&path) + .ok_or_else(|| anyhow!("cannot write to path {:?}", path))?; let mut response = this .host @@ -367,19 +438,24 @@ impl wit::ExtensionImports for WasmState { .await?; } wit::DownloadedFileType::Zip => { - let zip_filename = format!("{filename}.zip"); + let file_name = destination_path + .file_name() + .ok_or_else(|| anyhow!("invalid download path"))? + .to_string_lossy(); + let zip_filename = format!("{file_name}.zip"); let mut zip_path = destination_path.clone(); zip_path.set_file_name(zip_filename); + futures::pin_mut!(body); this.host.fs.create_file_with(&zip_path, body).await?; let unzip_status = std::process::Command::new("unzip") - .current_dir(&container_dir) + .current_dir(&extension_work_dir) .arg(&zip_path) .output()? .status; if !unzip_status.success() { - Err(anyhow!("failed to unzip {filename} archive"))?; + Err(anyhow!("failed to unzip {} archive", path.display()))?; } } } @@ -387,19 +463,23 @@ impl wit::ExtensionImports for WasmState { Ok(()) } - Ok(inner(self, url, filename, file_type) + Ok(inner(self, url, path, file_type) .await .map(|_| ()) .map_err(|err| err.to_string())) } } -impl WasiView for WasmState { +fn wasi_view(state: &mut WasmState) -> &mut WasmState { + state +} + +impl wasi::WasiView for WasmState { fn table(&mut self) -> &mut ResourceTable { &mut self.table } - fn ctx(&mut self) -> &mut WasiCtx { + fn ctx(&mut self) -> &mut wasi::WasiCtx { &mut self.ctx } } diff --git a/crates/extension_api/src/extension_api.rs b/crates/extension_api/src/extension_api.rs index 20332a85e3..b501d1daa0 100644 --- a/crates/extension_api/src/extension_api.rs +++ b/crates/extension_api/src/extension_api.rs @@ -20,6 +20,7 @@ macro_rules! register_extension { ($extension_type:ty) => { #[export_name = "init-extension"] pub extern "C" fn __init_extension() { + std::env::set_current_dir(std::env::var("PWD").unwrap()).unwrap(); zed_extension_api::register_extension(|| { Box::new(<$extension_type as zed_extension_api::Extension>::new()) }); diff --git a/crates/extensions_ui/src/extensions_ui.rs b/crates/extensions_ui/src/extensions_ui.rs index 759779472c..13d381ed99 100644 --- a/crates/extensions_ui/src/extensions_ui.rs +++ b/crates/extensions_ui/src/extensions_ui.rs @@ -45,7 +45,9 @@ pub fn init(cx: &mut AppContext) { let extension_path = prompt.await.log_err()??.pop()?; store .update(&mut cx, |store, cx| { - store.install_dev_extension(extension_path, cx); + store + .install_dev_extension(extension_path, cx) + .detach_and_log_err(cx) }) .ok()?; Some(()) @@ -93,9 +95,8 @@ impl ExtensionsPage { let subscriptions = [ cx.observe(&store, |_, _, cx| cx.notify()), cx.subscribe(&store, |this, _, event, cx| match event { - extension::Event::ExtensionsUpdated => { - this.fetch_extensions_debounced(cx); - } + extension::Event::ExtensionsUpdated => this.fetch_extensions_debounced(cx), + _ => {} }), ]; diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 2f787920b4..7f3dd153b3 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -9385,7 +9385,7 @@ impl> From<(WorktreeId, P)> for ProjectPath { } struct ProjectLspAdapterDelegate { - project: Model, + project: WeakModel, worktree: worktree::Snapshot, fs: Arc, http_client: Arc, @@ -9395,7 +9395,7 @@ struct ProjectLspAdapterDelegate { impl ProjectLspAdapterDelegate { fn new(project: &Project, worktree: &Model, cx: &ModelContext) -> Arc { Arc::new(Self { - project: cx.handle(), + project: cx.weak_model(), worktree: worktree.read(cx).snapshot(), fs: project.fs.clone(), http_client: project.client.http_client(), @@ -9408,7 +9408,8 @@ impl ProjectLspAdapterDelegate { impl LspAdapterDelegate for ProjectLspAdapterDelegate { fn show_notification(&self, message: &str, cx: &mut AppContext) { self.project - .update(cx, |_, cx| cx.emit(Event::Notification(message.to_owned()))); + .update(cx, |_, cx| cx.emit(Event::Notification(message.to_owned()))) + .ok(); } fn http_client(&self) -> Arc { diff --git a/extensions/gleam/src/gleam.rs b/extensions/gleam/src/gleam.rs index ffc8515802..f58d0d2256 100644 --- a/extensions/gleam/src/gleam.rs +++ b/extensions/gleam/src/gleam.rs @@ -1,9 +1,92 @@ +use std::fs; use zed_extension_api::{self as zed, Result}; struct GleamExtension { cached_binary_path: Option, } +impl GleamExtension { + fn language_server_binary_path(&mut self, config: zed::LanguageServerConfig) -> Result { + if let Some(path) = &self.cached_binary_path { + if fs::metadata(path).map_or(false, |stat| stat.is_file()) { + zed::set_language_server_installation_status( + &config.name, + &zed::LanguageServerInstallationStatus::Cached, + ); + return Ok(path.clone()); + } + } + + zed::set_language_server_installation_status( + &config.name, + &zed::LanguageServerInstallationStatus::CheckingForUpdate, + ); + let release = zed::latest_github_release( + "gleam-lang/gleam", + zed::GithubReleaseOptions { + require_assets: true, + pre_release: false, + }, + )?; + + let (platform, arch) = zed::current_platform(); + let asset_name = format!( + "gleam-{version}-{arch}-{os}.tar.gz", + version = release.version, + arch = match arch { + zed::Architecture::Aarch64 => "aarch64", + zed::Architecture::X86 => "x86", + zed::Architecture::X8664 => "x86_64", + }, + os = match platform { + zed::Os::Mac => "apple-darwin", + zed::Os::Linux => "unknown-linux-musl", + zed::Os::Windows => "pc-windows-msvc", + }, + ); + + let asset = release + .assets + .iter() + .find(|asset| asset.name == asset_name) + .ok_or_else(|| format!("no asset found matching {:?}", asset_name))?; + + let version_dir = format!("gleam-{}", release.version); + let binary_path = format!("{version_dir}/gleam"); + + if !fs::metadata(&binary_path).map_or(false, |stat| stat.is_file()) { + zed::set_language_server_installation_status( + &config.name, + &zed::LanguageServerInstallationStatus::Downloading, + ); + + zed::download_file( + &asset.download_url, + &version_dir, + zed::DownloadedFileType::GzipTar, + ) + .map_err(|e| format!("failed to download file: {e}"))?; + + let entries = + fs::read_dir(".").map_err(|e| format!("failed to list working directory {e}"))?; + for entry in entries { + let entry = entry.map_err(|e| format!("failed to load directory entry {e}"))?; + if entry.file_name().to_str() != Some(&version_dir) { + fs::remove_dir_all(&entry.path()).ok(); + } + } + + zed::set_language_server_installation_status( + &config.name, + &zed::LanguageServerInstallationStatus::Downloaded, + ); + } + + self.cached_binary_path = Some(binary_path.clone()); + Ok(binary_path) + } +} + impl zed::Extension for GleamExtension { fn new() -> Self { Self { @@ -16,72 +99,8 @@ impl zed::Extension for GleamExtension { config: zed::LanguageServerConfig, _worktree: &zed::Worktree, ) -> Result { - let binary_path = if let Some(path) = &self.cached_binary_path { - zed::set_language_server_installation_status( - &config.name, - &zed::LanguageServerInstallationStatus::Cached, - ); - - path.clone() - } else { - zed::set_language_server_installation_status( - &config.name, - &zed::LanguageServerInstallationStatus::CheckingForUpdate, - ); - let release = zed::latest_github_release( - "gleam-lang/gleam", - zed::GithubReleaseOptions { - require_assets: true, - pre_release: false, - }, - )?; - - let (platform, arch) = zed::current_platform(); - let asset_name = format!( - "gleam-{version}-{arch}-{os}.tar.gz", - version = release.version, - arch = match arch { - zed::Architecture::Aarch64 => "aarch64", - zed::Architecture::X86 => "x86", - zed::Architecture::X8664 => "x86_64", - }, - os = match platform { - zed::Os::Mac => "apple-darwin", - zed::Os::Linux => "unknown-linux-musl", - zed::Os::Windows => "pc-windows-msvc", - }, - ); - - let asset = release - .assets - .iter() - .find(|asset| asset.name == asset_name) - .ok_or_else(|| format!("no asset found matching {:?}", asset_name))?; - - zed::set_language_server_installation_status( - &config.name, - &zed::LanguageServerInstallationStatus::Downloading, - ); - let version_dir = format!("gleam-{}", release.version); - zed::download_file( - &asset.download_url, - &version_dir, - zed::DownloadedFileType::GzipTar, - ) - .map_err(|e| format!("failed to download file: {e}"))?; - - zed::set_language_server_installation_status( - &config.name, - &zed::LanguageServerInstallationStatus::Downloaded, - ); - - let binary_path = format!("{version_dir}/gleam"); - self.cached_binary_path = Some(binary_path.clone()); - binary_path - }; - Ok(zed::Command { - command: binary_path, + command: self.language_server_binary_path(config)?, args: vec!["lsp".to_string()], env: Default::default(), })