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(), })