logiguard fork: GPUI xdg-activation keyboard-focus serial fix
Some checks failed
Update All Top Ranking Issues / update_top_ranking_issues (push) Has been cancelled
Triage Project Sync (#84) / Sync triage project (push) Has been cancelled
release_nightly / notify_on_failure (push) Has been cancelled
release_nightly / check_style (push) Has been cancelled
release_nightly / run_tests_windows (push) Has been cancelled
release_nightly / clippy_windows (push) Has been cancelled
release_nightly / bundle_linux_aarch64 (push) Has been cancelled
release_nightly / bundle_linux_x86_64 (push) Has been cancelled
release_nightly / bundle_mac_aarch64 (push) Has been cancelled
release_nightly / bundle_mac_x86_64 (push) Has been cancelled
release_nightly / bundle_windows_aarch64 (push) Has been cancelled
release_nightly / bundle_windows_x86_64 (push) Has been cancelled
release_nightly / build_nix_linux_x86_64 (push) Has been cancelled
release_nightly / build_nix_mac_aarch64 (push) Has been cancelled
release_nightly / update_nightly_tag (push) Has been cancelled
Hotfix Review Monitor / check-hotfix-reviews (push) Has been cancelled
Stale PR Review Reminder / check-stale-prs (push) Has been cancelled
Update Weekly Top Ranking Issues / update_top_ranking_issues (push) Has been cancelled
Bump collab-staging Tag / update-collab-staging-tag (push) Has been cancelled
compliance_check / scheduled_compliance_check (push) Has been cancelled

Single-commit orphan branch: full zed-industries/zed @ 8c74db0 source tree
with a 3-file patch applied (no upstream history).

Patch (crates/gpui_linux/src/linux/wayland/):
  - serial.rs: add SerialKind::KeyboardEnter
  - client.rs: store wl_keyboard.enter serial; add latest_serial_of()
  - window.rs: activate() uses keyboard-enter serial (Mutter focus gate)

Mutter honors window activation only when the token carries the keyboard-
focus serial from wl_keyboard.enter; GPUI used a stale mouse-press serial.
See docs/tray-window-focus-wayland.md in logiguard.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mohamad Khani
2026-07-14 02:22:17 +03:30
commit b72a46db68
3984 changed files with 1583326 additions and 0 deletions

View File

@@ -0,0 +1,50 @@
[package]
name = "extension"
version = "0.1.0"
edition.workspace = true
publish.workspace = true
license = "GPL-3.0-or-later"
[lints]
workspace = true
[lib]
path = "src/extension.rs"
[dependencies]
anyhow.workspace = true
async-trait.workspace = true
cloud_api_types.workspace = true
collections.workspace = true
dap.workspace = true
fs.workspace = true
futures.workspace = true
gpui.workspace = true
heck.workspace = true
http_client.workspace = true
language.workspace = true
log.workspace = true
lsp.workspace = true
parking_lot.workspace = true
proto.workspace = true
semver.workspace = true
serde.workspace = true
serde_json.workspace = true
task.workspace = true
toml.workspace = true
tracing.workspace = true
url.workspace = true
util.workspace = true
wasm-encoder.workspace = true
wasmparser.workspace = true
ztracing.workspace = true
[dev-dependencies]
fs = { workspace = true, "features" = ["test-support"] }
gpui = { workspace = true, "features" = ["test-support"] }
indoc.workspace = true
pretty_assertions.workspace = true
tempfile.workspace = true
[package.metadata.cargo-machete]
ignored = ["tracing"]

View File

@@ -0,0 +1 @@
../../LICENSE-GPL

View File

@@ -0,0 +1,20 @@
mod download_file_capability;
mod npm_install_package_capability;
mod process_exec_capability;
pub use download_file_capability::*;
pub use npm_install_package_capability::*;
pub use process_exec_capability::*;
use serde::{Deserialize, Serialize};
/// A capability for an extension.
#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ExtensionCapability {
#[serde(rename = "process:exec")]
ProcessExec(ProcessExecCapability),
DownloadFile(DownloadFileCapability),
#[serde(rename = "npm:install")]
NpmInstallPackage(NpmInstallPackageCapability),
}

View File

@@ -0,0 +1,121 @@
use serde::{Deserialize, Serialize};
use url::Url;
#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct DownloadFileCapability {
pub host: String,
pub path: Vec<String>,
}
impl DownloadFileCapability {
/// Returns whether the capability allows downloading a file from the given URL.
pub fn allows(&self, url: &Url) -> bool {
let Some(desired_host) = url.host_str() else {
return false;
};
let Some(desired_path) = url.path_segments() else {
return false;
};
let desired_path = desired_path.collect::<Vec<_>>();
if self.host != desired_host && self.host != "*" {
return false;
}
for (ix, path_segment) in self.path.iter().enumerate() {
if path_segment == "**" {
return true;
}
if ix >= desired_path.len() {
return false;
}
if path_segment != "*" && path_segment != desired_path[ix] {
return false;
}
}
if self.path.len() < desired_path.len() {
return false;
}
true
}
}
#[cfg(test)]
mod tests {
use pretty_assertions::assert_eq;
use super::*;
#[test]
fn test_allows() {
let capability = DownloadFileCapability {
host: "*".to_string(),
path: vec!["**".to_string()],
};
assert_eq!(
capability.allows(&"https://example.com/some/path".parse().unwrap()),
true
);
let capability = DownloadFileCapability {
host: "github.com".to_string(),
path: vec!["**".to_string()],
};
assert_eq!(
capability.allows(&"https://github.com/some-owner/some-repo".parse().unwrap()),
true
);
assert_eq!(
capability.allows(
&"https://fake-github.com/some-owner/some-repo"
.parse()
.unwrap()
),
false
);
let capability = DownloadFileCapability {
host: "github.com".to_string(),
path: vec!["specific-owner".to_string(), "*".to_string()],
};
assert_eq!(
capability.allows(&"https://github.com/some-owner/some-repo".parse().unwrap()),
false
);
assert_eq!(
capability.allows(
&"https://github.com/specific-owner/some-repo"
.parse()
.unwrap()
),
true
);
let capability = DownloadFileCapability {
host: "github.com".to_string(),
path: vec!["specific-owner".to_string(), "*".to_string()],
};
assert_eq!(
capability.allows(
&"https://github.com/some-owner/some-repo/extra"
.parse()
.unwrap()
),
false
);
assert_eq!(
capability.allows(
&"https://github.com/specific-owner/some-repo/extra"
.parse()
.unwrap()
),
false
);
}
}

View File

@@ -0,0 +1,39 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct NpmInstallPackageCapability {
pub package: String,
}
impl NpmInstallPackageCapability {
/// Returns whether the capability allows installing the given NPM package.
pub fn allows(&self, package: &str) -> bool {
self.package == "*" || self.package == package
}
}
#[cfg(test)]
mod tests {
use pretty_assertions::assert_eq;
use super::*;
#[test]
fn test_allows() {
let capability = NpmInstallPackageCapability {
package: "*".to_string(),
};
assert_eq!(capability.allows("package"), true);
let capability = NpmInstallPackageCapability {
package: "react".to_string(),
};
assert_eq!(capability.allows("react"), true);
let capability = NpmInstallPackageCapability {
package: "react".to_string(),
};
assert_eq!(capability.allows("malicious-package"), false);
}
}

View File

@@ -0,0 +1,116 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct ProcessExecCapability {
/// The command to execute.
pub command: String,
/// The arguments to pass to the command. Use `*` for a single wildcard argument.
/// If the last element is `**`, then any trailing arguments are allowed.
pub args: Vec<String>,
}
impl ProcessExecCapability {
/// Returns whether the capability allows the given command and arguments.
pub fn allows(
&self,
desired_command: &str,
desired_args: &[impl AsRef<str> + std::fmt::Debug],
) -> bool {
if self.command != desired_command && self.command != "*" {
return false;
}
for (ix, arg) in self.args.iter().enumerate() {
if arg == "**" {
return true;
}
if ix >= desired_args.len() {
return false;
}
if arg != "*" && arg != desired_args[ix].as_ref() {
return false;
}
}
if self.args.len() < desired_args.len() {
return false;
}
true
}
}
#[cfg(test)]
mod tests {
use pretty_assertions::assert_eq;
use super::*;
#[test]
fn test_allows_with_exact_match() {
let capability = ProcessExecCapability {
command: "ls".to_string(),
args: vec!["-la".to_string()],
};
assert_eq!(capability.allows("ls", &["-la"]), true);
assert_eq!(capability.allows("ls", &["-l"]), false);
assert_eq!(capability.allows("pwd", &[] as &[&str]), false);
}
#[test]
fn test_allows_with_wildcard_arg() {
let capability = ProcessExecCapability {
command: "git".to_string(),
args: vec!["*".to_string()],
};
assert_eq!(capability.allows("git", &["status"]), true);
assert_eq!(capability.allows("git", &["commit"]), true);
// Too many args.
assert_eq!(capability.allows("git", &["status", "-s"]), false);
// Wrong command.
assert_eq!(capability.allows("npm", &["install"]), false);
}
#[test]
fn test_allows_with_double_wildcard() {
let capability = ProcessExecCapability {
command: "cargo".to_string(),
args: vec!["test".to_string(), "**".to_string()],
};
assert_eq!(capability.allows("cargo", &["test"]), true);
assert_eq!(capability.allows("cargo", &["test", "--all"]), true);
assert_eq!(
capability.allows("cargo", &["test", "--all", "--no-fail-fast"]),
true
);
// Wrong first arg.
assert_eq!(capability.allows("cargo", &["build"]), false);
}
#[test]
fn test_allows_with_mixed_wildcards() {
let capability = ProcessExecCapability {
command: "docker".to_string(),
args: vec!["run".to_string(), "*".to_string(), "**".to_string()],
};
assert_eq!(capability.allows("docker", &["run", "nginx"]), true);
assert_eq!(capability.allows("docker", &["run"]), false);
assert_eq!(
capability.allows("docker", &["run", "ubuntu", "bash"]),
true
);
assert_eq!(
capability.allows("docker", &["run", "alpine", "sh", "-c", "echo hello"]),
true
);
// Wrong first arg.
assert_eq!(capability.allows("docker", &["ps"]), false);
}
}

View File

@@ -0,0 +1,221 @@
mod capabilities;
pub mod extension_builder;
mod extension_events;
mod extension_host_proxy;
mod extension_manifest;
mod types;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use ::lsp::LanguageServerName;
use anyhow::{Context as _, Result, bail};
use async_trait::async_trait;
use gpui::{App, Task};
use language::LanguageName;
use semver::Version;
use task::{SpawnInTerminal, ZedDebugConfig};
use util::rel_path::RelPath;
pub use crate::capabilities::*;
pub use crate::extension_events::*;
pub use crate::extension_host_proxy::*;
pub use crate::extension_manifest::*;
pub use crate::types::*;
/// Initializes the `extension` crate.
pub fn init(cx: &mut App) {
extension_events::init(cx);
ExtensionHostProxy::default_global(cx);
}
#[async_trait]
pub trait WorktreeDelegate: Send + Sync + 'static {
fn id(&self) -> u64;
fn root_path(&self) -> String;
async fn read_text_file(&self, path: &RelPath) -> Result<String>;
async fn which(&self, binary_name: String) -> Option<String>;
async fn shell_env(&self) -> Vec<(String, String)>;
}
pub trait ProjectDelegate: Send + Sync + 'static {
fn worktree_ids(&self) -> Vec<u64>;
}
pub trait KeyValueStoreDelegate: Send + Sync + 'static {
fn insert(&self, key: String, docs: String) -> Task<Result<()>>;
}
#[async_trait]
pub trait Extension: Send + Sync + 'static {
/// Returns the [`ExtensionManifest`] for this extension.
fn manifest(&self) -> Arc<ExtensionManifest>;
/// Returns the path to this extension's working directory.
fn work_dir(&self) -> Arc<Path>;
/// Returns a path relative to this extension's working directory.
fn path_from_extension(&self, path: &Path) -> PathBuf {
util::normalize_path(&self.work_dir().join(path))
}
async fn language_server_command(
&self,
language_server_id: LanguageServerName,
language_name: LanguageName,
worktree: Arc<dyn WorktreeDelegate>,
) -> Result<Command>;
async fn language_server_initialization_options(
&self,
language_server_id: LanguageServerName,
language_name: LanguageName,
worktree: Arc<dyn WorktreeDelegate>,
) -> Result<Option<String>>;
async fn language_server_workspace_configuration(
&self,
language_server_id: LanguageServerName,
worktree: Arc<dyn WorktreeDelegate>,
) -> Result<Option<String>>;
async fn language_server_initialization_options_schema(
&self,
language_server_id: LanguageServerName,
worktree: Arc<dyn WorktreeDelegate>,
) -> Result<Option<String>>;
async fn language_server_workspace_configuration_schema(
&self,
language_server_id: LanguageServerName,
worktree: Arc<dyn WorktreeDelegate>,
) -> Result<Option<String>>;
async fn language_server_additional_initialization_options(
&self,
language_server_id: LanguageServerName,
target_language_server_id: LanguageServerName,
worktree: Arc<dyn WorktreeDelegate>,
) -> Result<Option<String>>;
async fn language_server_additional_workspace_configuration(
&self,
language_server_id: LanguageServerName,
target_language_server_id: LanguageServerName,
worktree: Arc<dyn WorktreeDelegate>,
) -> Result<Option<String>>;
async fn labels_for_completions(
&self,
language_server_id: LanguageServerName,
completions: Vec<Completion>,
) -> Result<Vec<Option<CodeLabel>>>;
async fn labels_for_symbols(
&self,
language_server_id: LanguageServerName,
symbols: Vec<Symbol>,
) -> Result<Vec<Option<CodeLabel>>>;
async fn complete_slash_command_argument(
&self,
command: SlashCommand,
arguments: Vec<String>,
) -> Result<Vec<SlashCommandArgumentCompletion>>;
async fn run_slash_command(
&self,
command: SlashCommand,
arguments: Vec<String>,
worktree: Option<Arc<dyn WorktreeDelegate>>,
) -> Result<SlashCommandOutput>;
async fn context_server_command(
&self,
context_server_id: Arc<str>,
project: Arc<dyn ProjectDelegate>,
) -> Result<Command>;
async fn context_server_configuration(
&self,
context_server_id: Arc<str>,
project: Arc<dyn ProjectDelegate>,
) -> Result<Option<ContextServerConfiguration>>;
async fn suggest_docs_packages(&self, provider: Arc<str>) -> Result<Vec<String>>;
async fn index_docs(
&self,
provider: Arc<str>,
package_name: Arc<str>,
kv_store: Arc<dyn KeyValueStoreDelegate>,
) -> Result<()>;
async fn get_dap_binary(
&self,
dap_name: Arc<str>,
config: DebugTaskDefinition,
user_installed_path: Option<PathBuf>,
worktree: Arc<dyn WorktreeDelegate>,
) -> Result<DebugAdapterBinary>;
async fn dap_request_kind(
&self,
dap_name: Arc<str>,
config: serde_json::Value,
) -> Result<StartDebuggingRequestArgumentsRequest>;
async fn dap_config_to_scenario(&self, config: ZedDebugConfig) -> Result<DebugScenario>;
async fn dap_locator_create_scenario(
&self,
locator_name: String,
build_config_template: BuildTaskTemplate,
resolved_label: String,
debug_adapter_name: String,
) -> Result<Option<DebugScenario>>;
async fn run_dap_locator(
&self,
locator_name: String,
config: SpawnInTerminal,
) -> Result<DebugRequest>;
}
pub fn parse_wasm_extension_version(extension_id: &str, wasm_bytes: &[u8]) -> Result<Version> {
let mut version = None;
for part in wasmparser::Parser::new(0).parse_all(wasm_bytes) {
if let wasmparser::Payload::CustomSection(s) =
part.context("error parsing wasm extension")?
&& s.name() == "zed:api-version"
{
version = parse_wasm_extension_version_custom_section(s.data());
if version.is_none() {
bail!(
"extension {} has invalid zed:api-version section: {:?}",
extension_id,
s.data()
);
}
}
}
// The reason we wait until we're done parsing all of the Wasm bytes to return the version
// is to work around a panic that can happen inside of Wasmtime when the bytes are invalid.
//
// By parsing the entirety of the Wasm bytes before we return, we're able to detect this problem
// earlier as an `Err` rather than as a panic.
version.with_context(|| format!("extension {extension_id} has no zed:api-version section"))
}
fn parse_wasm_extension_version_custom_section(data: &[u8]) -> Option<Version> {
if data.len() == 6 {
Some(Version::new(
u16::from_be_bytes([data[0], data[1]]) as _,
u16::from_be_bytes([data[2], data[3]]) as _,
u16::from_be_bytes([data[4], data[5]]) as _,
))
} else {
None
}
}

View File

@@ -0,0 +1,830 @@
use crate::{
ExtensionLibraryKind, ExtensionManifest, GrammarManifestEntry, build_debug_adapter_schema_path,
parse_wasm_extension_version,
};
use ::fs::Fs;
use anyhow::{Context as _, Result, bail};
use futures::{StreamExt, io};
use heck::ToSnakeCase;
use http_client::{self, AsyncBody, HttpClient};
use language::LanguageConfig;
use serde::Deserialize;
use std::{
env, fs, mem,
path::{Path, PathBuf},
str::FromStr,
sync::Arc,
};
use util::{command::Stdio, rel_path::PathExt};
use wasm_encoder::{ComponentSectionId, Encode as _, RawSection, Section as _};
use wasmparser::Parser;
/// Currently, we compile with Rust's `wasm32-wasip2` target, which works with WASI `preview2` and the component model.
const RUST_TARGET: &str = "wasm32-wasip2";
/// Compiling Tree-sitter parsers from C to WASM requires Clang 17, and a WASM build of libc
/// and clang's runtime library. The `wasi-sdk` provides these binaries.
///
/// Once Clang 17 and its wasm target are available via system package managers, we won't need
/// to download this.
const WASI_SDK_URL: &str = "https://github.com/WebAssembly/wasi-sdk/releases/download/wasi-sdk-25/";
const WASI_SDK_ASSET_NAME: Option<&str> = cfg_select! {
all(target_os = "macos", target_arch = "x86_64") => Some("wasi-sdk-25.0-x86_64-macos.tar.gz"),
all(target_os = "macos", target_arch = "aarch64") => Some("wasi-sdk-25.0-arm64-macos.tar.gz"),
all(target_os = "linux", target_arch = "x86_64") => Some("wasi-sdk-25.0-x86_64-linux.tar.gz"),
all(target_os = "linux", target_arch = "aarch64") => Some("wasi-sdk-25.0-arm64-linux.tar.gz"),
all(target_os = "freebsd", target_arch = "x86_64") => Some("wasi-sdk-25.0-x86_64-linux.tar.gz"),
all(target_os = "freebsd", target_arch = "aarch64") => Some("wasi-sdk-25.0-arm64-linux.tar.gz"),
all(target_os = "windows", target_arch = "x86_64") => Some("wasi-sdk-25.0-x86_64-windows.tar.gz"),
_ => None
};
pub struct ExtensionBuilder {
cache_dir: PathBuf,
pub http: Arc<dyn HttpClient>,
}
pub struct CompileExtensionOptions {
pub release: bool,
}
impl CompileExtensionOptions {
pub const fn dev() -> Self {
Self { release: false }
}
}
#[derive(Deserialize)]
struct CargoToml {
package: CargoTomlPackage,
}
#[derive(Deserialize)]
struct CargoTomlPackage {
name: String,
}
impl ExtensionBuilder {
pub fn new(http_client: Arc<dyn HttpClient>, cache_dir: PathBuf) -> Self {
Self {
cache_dir,
http: http_client,
}
}
pub async fn compile_extension(
&self,
extension_dir: &Path,
extension_manifest: &mut ExtensionManifest,
options: CompileExtensionOptions,
fs: Arc<dyn Fs>,
) -> Result<()> {
populate_defaults(extension_manifest, extension_dir, fs).await?;
if extension_dir.is_relative() {
bail!(
"extension dir {} is not an absolute path",
extension_dir.display()
);
}
fs::create_dir_all(&self.cache_dir).context("failed to create cache dir")?;
if extension_manifest.lib.kind == Some(ExtensionLibraryKind::Rust) {
log::info!("compiling Rust extension {}", extension_dir.display());
self.compile_rust_extension(extension_dir, extension_manifest, options)
.await
.context("failed to compile Rust extension")?;
log::info!("compiled Rust extension {}", extension_dir.display());
}
for (debug_adapter_name, meta) in &mut extension_manifest.debug_adapters {
let debug_adapter_schema_path =
extension_dir.join(build_debug_adapter_schema_path(debug_adapter_name, meta)?);
let debug_adapter_schema = fs::read_to_string(&debug_adapter_schema_path)
.with_context(|| {
format!("failed to read debug adapter schema for `{debug_adapter_name}` from `{debug_adapter_schema_path:?}`")
})?;
_ = serde_json::Value::from_str(&debug_adapter_schema).with_context(|| {
format!("Debug adapter schema for `{debug_adapter_name}` (path: `{debug_adapter_schema_path:?}`) is not a valid JSON")
})?;
}
for (grammar_name, grammar_metadata) in &extension_manifest.grammars {
let snake_cased_grammar_name = grammar_name.to_snake_case();
if grammar_name.as_ref() != snake_cased_grammar_name.as_str() {
bail!(
"grammar name '{grammar_name}' must be written in snake_case: {snake_cased_grammar_name}"
);
}
log::info!(
"compiling grammar {grammar_name} for extension {}",
extension_dir.display()
);
self.compile_grammar(extension_dir, grammar_name.as_ref(), grammar_metadata)
.await
.with_context(|| format!("failed to compile grammar '{grammar_name}'"))?;
log::info!(
"compiled grammar {grammar_name} for extension {}",
extension_dir.display()
);
}
log::info!("finished compiling extension {}", extension_dir.display());
Ok(())
}
async fn compile_rust_extension(
&self,
extension_dir: &Path,
manifest: &mut ExtensionManifest,
options: CompileExtensionOptions,
) -> anyhow::Result<()> {
self.install_rust_wasm_target_if_needed().await?;
let cargo_toml_content = fs::read_to_string(extension_dir.join("Cargo.toml"))?;
let cargo_toml: CargoToml = toml::from_str(&cargo_toml_content)?;
log::info!(
"compiling Rust crate for extension {}",
extension_dir.display()
);
let output = util::command::new_command("cargo")
.args(["build", "--target", RUST_TARGET])
.args(options.release.then_some("--release"))
.arg("--target-dir")
.arg(extension_dir.join("target"))
// WASI builds do not work with sccache and just stuck, so disable it.
.env("RUSTC_WRAPPER", "")
.current_dir(extension_dir)
.output()
.await
.context("failed to run `cargo`")?;
if !output.status.success() {
bail!(
"failed to build extension {}",
String::from_utf8_lossy(&output.stderr)
);
}
log::info!(
"compiled Rust crate for extension {}",
extension_dir.display()
);
let mut wasm_path = PathBuf::from(extension_dir);
wasm_path.extend([
"target",
RUST_TARGET,
if options.release { "release" } else { "debug" },
&cargo_toml
.package
.name
// The wasm32-wasip2 target normalizes `-` in package names to `_` in the resulting `.wasm` file.
.replace('-', "_"),
]);
wasm_path.set_extension("wasm");
log::info!(
"encoding wasm component for extension {}",
extension_dir.display()
);
let component_bytes = fs::read(&wasm_path)
.with_context(|| format!("failed to read output module `{}`", wasm_path.display()))?;
let component_bytes = self
.strip_custom_sections(&component_bytes)
.context("failed to strip debug sections from wasm component")?;
let wasm_extension_api_version =
parse_wasm_extension_version(&manifest.id, &component_bytes)
.context("compiled wasm did not contain a valid zed extension api version")?;
manifest.lib.version = Some(wasm_extension_api_version);
let extension_file = extension_dir.join("extension.wasm");
fs::write(extension_file.clone(), &component_bytes)
.context("failed to write extension.wasm")?;
log::info!(
"extension {} written to {}",
extension_dir.display(),
extension_file.display()
);
Ok(())
}
async fn compile_grammar(
&self,
extension_dir: &Path,
grammar_name: &str,
grammar_metadata: &GrammarManifestEntry,
) -> Result<()> {
let clang_path = self.install_wasi_sdk_if_needed().await?;
let mut grammar_repo_dir = extension_dir.to_path_buf();
grammar_repo_dir.extend(["grammars", grammar_name]);
let mut grammar_wasm_path = grammar_repo_dir.clone();
grammar_wasm_path.set_extension("wasm");
log::info!("checking out {grammar_name} parser");
self.checkout_repo(
&grammar_repo_dir,
&grammar_metadata.repository,
&grammar_metadata.rev,
)
.await?;
let base_grammar_path = grammar_metadata
.path
.as_ref()
.map(|path| grammar_repo_dir.join(path))
.unwrap_or(grammar_repo_dir);
let src_path = base_grammar_path.join("src");
let parser_path = src_path.join("parser.c");
let scanner_path = src_path.join("scanner.c");
// Skip recompiling if the WASM object is already newer than the source files
if file_newer_than_deps(&grammar_wasm_path, &[&parser_path, &scanner_path]).unwrap_or(false)
{
log::info!(
"skipping compilation of {grammar_name} parser because the existing compiled grammar is up to date"
);
} else {
log::info!("compiling {grammar_name} parser");
let clang_output = util::command::new_command(&clang_path)
.args(["-fPIC", "-shared", "-Os"])
.arg(format!("-Wl,--export=tree_sitter_{grammar_name}"))
.arg("-o")
.arg(&grammar_wasm_path)
.arg("-I")
.arg(&src_path)
.arg(&parser_path)
.args(scanner_path.exists().then_some(scanner_path))
.output()
.await
.context("failed to run clang")?;
if !clang_output.status.success() {
bail!(
"failed to compile {} parser with clang: {}",
grammar_name,
String::from_utf8_lossy(&clang_output.stderr),
);
}
}
Ok(())
}
async fn checkout_repo(&self, directory: &Path, url: &str, rev: &str) -> Result<()> {
let git_dir = directory.join(".git");
if directory.exists() {
let remotes_output = util::command::new_command("git")
.arg("--git-dir")
.arg(&git_dir)
.args(["remote", "get-url", "origin"])
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.output()
.await?;
let has_remote = remotes_output.status.success()
&& String::from_utf8_lossy(&remotes_output.stdout).trim() == url;
if !has_remote {
bail!(
"grammar directory '{}' already exists, but is not a git clone of '{}'",
directory.display(),
url
);
}
} else {
fs::create_dir_all(directory).with_context(|| {
format!("failed to create grammar directory {}", directory.display(),)
})?;
let init_output = util::command::new_command("git")
.arg("init")
.current_dir(directory)
.output()
.await?;
if !init_output.status.success() {
bail!(
"failed to run `git init` in directory '{}'",
directory.display()
);
}
let remote_add_output = util::command::new_command("git")
.arg("--git-dir")
.arg(&git_dir)
.args(["remote", "add", "origin", url])
.output()
.await
.context("failed to execute `git remote add`")?;
if !remote_add_output.status.success() {
bail!(
"failed to add remote {url} for git repository {}",
git_dir.display()
);
}
}
let fetch_output = util::command::new_command("git")
.arg("--git-dir")
.arg(&git_dir)
.args(["fetch", "--depth", "1", "origin", rev])
.output()
.await
.context("failed to execute `git fetch`")?;
let checkout_output = util::command::new_command("git")
.arg("--git-dir")
.arg(&git_dir)
.args(["checkout", rev])
.current_dir(directory)
.output()
.await
.context("failed to execute `git checkout`")?;
if !checkout_output.status.success() {
if !fetch_output.status.success() {
bail!(
"failed to fetch revision {} in directory '{}'",
rev,
directory.display()
);
}
bail!(
"failed to checkout revision {} in directory '{}': {}",
rev,
directory.display(),
String::from_utf8_lossy(&checkout_output.stderr)
);
}
Ok(())
}
async fn install_rust_wasm_target_if_needed(&self) -> Result<()> {
let rustc_output = util::command::new_command("rustc")
.arg("--print")
.arg("sysroot")
.output()
.await
.context("failed to run rustc")?;
if !rustc_output.status.success() {
bail!(
"failed to retrieve rust sysroot: {}",
String::from_utf8_lossy(&rustc_output.stderr)
);
}
let sysroot = PathBuf::from(String::from_utf8(rustc_output.stdout)?.trim());
if sysroot.join("lib/rustlib").join(RUST_TARGET).exists() {
return Ok(());
}
let output = util::command::new_command("rustup")
.args(["target", "add", RUST_TARGET])
.stderr(Stdio::piped())
.stdout(Stdio::inherit())
.output()
.await
.context("failed to run `rustup target add`")?;
if !output.status.success() {
bail!(
"failed to install the `{RUST_TARGET}` target: {}",
String::from_utf8_lossy(&rustc_output.stderr)
);
}
Ok(())
}
async fn install_wasi_sdk_if_needed(&self) -> Result<PathBuf> {
let url = if let Some(asset_name) = WASI_SDK_ASSET_NAME {
format!("{WASI_SDK_URL}{asset_name}")
} else {
bail!("wasi-sdk is not available for platform {}", env::consts::OS);
};
let wasi_sdk_dir = self.cache_dir.join("wasi-sdk");
let mut clang_path = wasi_sdk_dir.clone();
clang_path.extend(["bin", &format!("clang{}", env::consts::EXE_SUFFIX)]);
log::info!("downloading wasi-sdk to {}", wasi_sdk_dir.display());
if fs::metadata(&clang_path).is_ok_and(|metadata| metadata.is_file()) {
return Ok(clang_path);
}
let tar_out_dir = self.cache_dir.join("wasi-sdk-temp");
fs::remove_dir_all(&wasi_sdk_dir).ok();
fs::remove_dir_all(&tar_out_dir).ok();
fs::create_dir_all(&tar_out_dir).context("failed to create extraction directory")?;
let mut response = self.http.get(&url, AsyncBody::default(), true).await?;
// Write the response to a temporary file
let tar_gz_path = self.cache_dir.join("wasi-sdk.tar.gz");
let tar_gz_file =
fs::File::create(&tar_gz_path).context("failed to create temporary tar.gz file")?;
let response_body = response.body_mut();
let mut async_file = io::AllowStdIo::new(tar_gz_file);
io::copy(response_body, &mut async_file)
.await
.context("failed to stream response to file")?;
drop(async_file);
log::info!("un-tarring wasi-sdk to {}", tar_out_dir.display());
// Shell out to tar to extract the archive
let tar_output = util::command::new_command("tar")
.arg("-xzf")
.arg(&tar_gz_path)
.arg("-C")
.arg(&tar_out_dir)
.output()
.await
.context("failed to run tar")?;
if !tar_output.status.success() {
bail!(
"failed to extract wasi-sdk archive: {}",
String::from_utf8_lossy(&tar_output.stderr)
);
}
log::info!("finished downloading wasi-sdk");
// Clean up the temporary tar.gz file
fs::remove_file(&tar_gz_path).ok();
let inner_dir = fs::read_dir(&tar_out_dir)?
.next()
.context("no content")?
.context("failed to read contents of extracted wasi archive directory")?
.path();
fs::rename(&inner_dir, &wasi_sdk_dir).context("failed to move extracted wasi dir")?;
fs::remove_dir_all(&tar_out_dir).ok();
Ok(clang_path)
}
// This was adapted from:
// https://github.com/bytecodealliance/wasm-tools/blob/e8809bb17fcf69aa8c85cd5e6db7cff5cf36b1de/src/bin/wasm-tools/strip.rs
fn strip_custom_sections(&self, input: &Vec<u8>) -> Result<Vec<u8>> {
use wasmparser::Payload::*;
let strip_custom_section = |name: &str| {
// Default strip everything but:
// * the `name` section
// * any `component-type` sections
// * the `dylink.0` section
// * our custom version section
name != "name"
&& !name.starts_with("component-type:")
&& name != "dylink.0"
&& name != "zed:api-version"
};
let mut output = Vec::new();
let mut stack = Vec::new();
for payload in Parser::new(0).parse_all(input) {
let payload = payload?;
// Track nesting depth, so that we don't mess with inner producer sections:
match payload {
Version { encoding, .. } => {
output.extend_from_slice(match encoding {
wasmparser::Encoding::Component => &wasm_encoder::Component::HEADER,
wasmparser::Encoding::Module => &wasm_encoder::Module::HEADER,
});
}
ModuleSection { .. } | ComponentSection { .. } => {
stack.push(mem::take(&mut output));
continue;
}
End { .. } => {
let mut parent = match stack.pop() {
Some(c) => c,
None => break,
};
if output.starts_with(&wasm_encoder::Component::HEADER) {
parent.push(ComponentSectionId::Component as u8);
output.encode(&mut parent);
} else {
parent.push(ComponentSectionId::CoreModule as u8);
output.encode(&mut parent);
}
output = parent;
}
_ => {}
}
if let CustomSection(c) = &payload
&& strip_custom_section(c.name())
{
continue;
}
if let Some((id, range)) = payload.as_section() {
RawSection {
id,
data: &input[range],
}
.append_to(&mut output);
}
}
Ok(output)
}
}
async fn populate_defaults(
manifest: &mut ExtensionManifest,
extension_path: &Path,
fs: Arc<dyn Fs>,
) -> Result<()> {
// For legacy extensions on the v0 schema (aka, using `extension.json`), clear out any existing
// contents of the computed fields, since we don't care what the existing values are.
if manifest.schema_version.is_v0() {
manifest.languages.clear();
manifest.grammars.clear();
manifest.themes.clear();
}
let cargo_toml_path = extension_path.join("Cargo.toml");
if cargo_toml_path.exists() {
manifest.lib.kind = Some(ExtensionLibraryKind::Rust);
}
let languages_dir = extension_path.join("languages");
if fs.is_dir(&languages_dir).await {
let mut language_dir_entries = fs
.read_dir(&languages_dir)
.await
.context("failed to list languages dir")?;
while let Some(language_dir) = language_dir_entries.next().await {
let language_dir = language_dir?;
let config_path = language_dir.join(LanguageConfig::FILE_NAME);
if fs.is_file(config_path.as_path()).await {
let relative_language_dir = language_dir
.strip_prefix(extension_path)?
.to_rel_path_buf()?;
if !manifest.languages.contains(&relative_language_dir) {
manifest.languages.push(relative_language_dir);
}
}
}
}
let themes_dir = extension_path.join("themes");
if fs.is_dir(&themes_dir).await {
let mut theme_dir_entries = fs
.read_dir(&themes_dir)
.await
.context("failed to list themes dir")?;
while let Some(theme_path) = theme_dir_entries.next().await {
let theme_path = theme_path?;
if theme_path.extension() == Some("json".as_ref()) {
let relative_theme_path =
theme_path.strip_prefix(extension_path)?.to_rel_path_buf()?;
if !manifest.themes.contains(&relative_theme_path) {
manifest.themes.push(relative_theme_path);
}
}
}
}
let icon_themes_dir = extension_path.join("icon_themes");
if fs.is_dir(&icon_themes_dir).await {
let mut icon_theme_dir_entries = fs
.read_dir(&icon_themes_dir)
.await
.context("failed to list icon themes dir")?;
while let Some(icon_theme_path) = icon_theme_dir_entries.next().await {
let icon_theme_path = icon_theme_path?;
if icon_theme_path.extension() == Some("json".as_ref()) {
let relative_icon_theme_path = icon_theme_path
.strip_prefix(extension_path)?
.to_rel_path_buf()?;
if !manifest.icon_themes.contains(&relative_icon_theme_path) {
manifest.icon_themes.push(relative_icon_theme_path);
}
}
}
};
if manifest.snippets.is_none()
&& let snippets_json_path = extension_path.join("snippets.json")
&& fs.is_file(&snippets_json_path).await
{
manifest.snippets = Some("snippets.json".into());
}
// For legacy extensions on the v0 schema (aka, using `extension.json`), we want to populate the grammars in
// the manifest using the contents of the `grammars` directory.
if manifest.schema_version.is_v0() {
let grammars_dir = extension_path.join("grammars");
if fs.is_dir(&grammars_dir).await {
let mut grammar_dir_entries = fs
.read_dir(&grammars_dir)
.await
.context("failed to list grammars dir")?;
while let Some(grammar_path) = grammar_dir_entries.next().await {
let grammar_path = grammar_path?;
if grammar_path.extension() == Some("toml".as_ref()) {
#[derive(Deserialize)]
struct GrammarConfigToml {
pub repository: String,
pub commit: String,
#[serde(default)]
pub path: Option<String>,
}
let grammar_config = fs.load(&grammar_path).await?;
let grammar_config: GrammarConfigToml = toml::from_str(&grammar_config)?;
let grammar_name = grammar_path
.file_stem()
.and_then(|stem| stem.to_str())
.context("no grammar name")?;
if !manifest.grammars.contains_key(grammar_name) {
manifest.grammars.insert(
grammar_name.into(),
GrammarManifestEntry {
repository: grammar_config.repository,
rev: grammar_config.commit,
path: grammar_config.path,
},
);
}
}
}
}
}
Ok(())
}
/// Returns `true` if the target exists and its last modified time is greater than that
/// of each dependency which exists (i.e., dependency paths which do not exist are ignored).
///
/// # Errors
///
/// Returns `Err` if any of the underlying file I/O operations fail.
fn file_newer_than_deps(target: &Path, dependencies: &[&Path]) -> Result<bool, std::io::Error> {
if !target.try_exists()? {
return Ok(false);
}
let target_modified = target.metadata()?.modified()?;
for dependency in dependencies {
if !dependency.try_exists()? {
continue;
}
let dep_modified = dependency.metadata()?.modified()?;
if target_modified < dep_modified {
return Ok(false);
}
}
Ok(true)
}
#[cfg(test)]
mod tests {
use std::{
path::{Path, PathBuf},
str::FromStr,
thread::sleep,
time::Duration,
};
use gpui::TestAppContext;
use indoc::indoc;
use crate::{
ExtensionManifest, ExtensionSnippets,
extension_builder::{file_newer_than_deps, populate_defaults},
};
#[test]
fn test_file_newer_than_deps() {
// Don't use TempTree because we need to guarantee the order
let tmpdir = tempfile::tempdir().unwrap();
let target = tmpdir.path().join("target.wasm");
let dep1 = tmpdir.path().join("parser.c");
let dep2 = tmpdir.path().join("scanner.c");
assert!(
!file_newer_than_deps(&target, &[&dep1, &dep2]).unwrap(),
"target doesn't exist"
);
std::fs::write(&target, "foo").unwrap(); // Create target
assert!(
file_newer_than_deps(&target, &[&dep1, &dep2]).unwrap(),
"dependencies don't exist; target is newer"
);
sleep(Duration::from_secs(1));
std::fs::write(&dep1, "foo").unwrap(); // Create dep1 (newer than target)
// Dependency is newer
assert!(
!file_newer_than_deps(&target, &[&dep1, &dep2]).unwrap(),
"a dependency is newer (target {:?}, dep1 {:?})",
target.metadata().unwrap().modified().unwrap(),
dep1.metadata().unwrap().modified().unwrap(),
);
sleep(Duration::from_secs(1));
std::fs::write(&dep2, "foo").unwrap(); // Create dep2
sleep(Duration::from_secs(1));
std::fs::write(&target, "foobar").unwrap(); // Update target
assert!(
file_newer_than_deps(&target, &[&dep1, &dep2]).unwrap(),
"target is newer than dependencies (target {:?}, dep2 {:?})",
target.metadata().unwrap().modified().unwrap(),
dep2.metadata().unwrap().modified().unwrap(),
);
}
#[gpui::test]
async fn test_snippet_location_is_kept(cx: &mut TestAppContext) {
let fs = fs::FakeFs::new(cx.executor());
let extension_path = Path::new("/extension");
fs.insert_tree(
extension_path,
serde_json::json!({
"extension.toml": indoc! {r#"
id = "test-manifest"
name = "Test Manifest"
version = "0.0.1"
schema_version = 1
snippets = "./snippets/snippets.json"
"#
},
"snippets.json": "",
}),
)
.await;
let mut manifest = ExtensionManifest::load(fs.clone(), extension_path)
.await
.unwrap();
populate_defaults(&mut manifest, extension_path, fs.clone())
.await
.unwrap();
assert_eq!(
manifest.snippets,
Some(ExtensionSnippets::Single(
PathBuf::from_str("./snippets/snippets.json").unwrap()
))
)
}
#[gpui::test]
async fn test_automatic_snippet_location_is_relative(cx: &mut TestAppContext) {
let fs = fs::FakeFs::new(cx.executor());
let extension_path = Path::new("/extension");
fs.insert_tree(
extension_path,
serde_json::json!({
"extension.toml": indoc! {r#"
id = "test-manifest"
name = "Test Manifest"
version = "0.0.1"
schema_version = 1
"#
},
"snippets.json": "",
}),
)
.await;
let mut manifest = ExtensionManifest::load(fs.clone(), extension_path)
.await
.unwrap();
populate_defaults(&mut manifest, extension_path, fs.clone())
.await
.unwrap();
assert_eq!(
manifest.snippets,
Some(ExtensionSnippets::Single(
PathBuf::from_str("snippets.json").unwrap()
))
)
}
}

View File

@@ -0,0 +1,43 @@
use std::sync::Arc;
use gpui::{App, AppContext as _, Context, Entity, EventEmitter, Global};
use crate::ExtensionManifest;
pub fn init(cx: &mut App) {
let extension_events = cx.new(ExtensionEvents::new);
cx.set_global(GlobalExtensionEvents(extension_events));
}
struct GlobalExtensionEvents(Entity<ExtensionEvents>);
impl Global for GlobalExtensionEvents {}
/// An event bus for broadcasting extension-related events throughout the app.
pub struct ExtensionEvents;
impl ExtensionEvents {
/// Returns the global [`ExtensionEvents`].
pub fn try_global(cx: &App) -> Option<Entity<Self>> {
cx.try_global::<GlobalExtensionEvents>()
.map(|g| g.0.clone())
}
fn new(_cx: &mut Context<Self>) -> Self {
Self
}
pub fn emit(&mut self, event: Event, cx: &mut Context<Self>) {
cx.emit(event)
}
}
#[derive(Clone, Debug)]
pub enum Event {
ExtensionInstalled(Arc<ExtensionManifest>),
ExtensionUninstalled(Arc<ExtensionManifest>),
ExtensionsInstalledChanged,
ConfigureExtensionRequested(Arc<ExtensionManifest>),
}
impl EventEmitter<Event> for ExtensionEvents {}

View File

@@ -0,0 +1,468 @@
use std::path::{Path, PathBuf};
use std::sync::Arc;
use anyhow::Result;
use fs::Fs;
use gpui::{App, Global, ReadGlobal, SharedString, Task};
use language::{BinaryStatus, LanguageMatcher, LanguageName, LoadedLanguage};
use lsp::LanguageServerName;
use parking_lot::RwLock;
use crate::Extension;
#[derive(Default)]
struct GlobalExtensionHostProxy(Arc<ExtensionHostProxy>);
impl Global for GlobalExtensionHostProxy {}
/// A proxy for interacting with the extension host.
///
/// This object implements each of the individual proxy types so that their
/// methods can be called directly on it.
/// Registration function for language model providers.
pub type LanguageModelProviderRegistration = Box<dyn FnOnce(&mut App) + Send>;
#[derive(Default)]
pub struct ExtensionHostProxy {
theme_proxy: RwLock<Option<Arc<dyn ExtensionThemeProxy>>>,
grammar_proxy: RwLock<Option<Arc<dyn ExtensionGrammarProxy>>>,
language_proxy: RwLock<Option<Arc<dyn ExtensionLanguageProxy>>>,
language_server_proxy: RwLock<Option<Arc<dyn ExtensionLanguageServerProxy>>>,
snippet_proxy: RwLock<Option<Arc<dyn ExtensionSnippetProxy>>>,
context_server_proxy: RwLock<Option<Arc<dyn ExtensionContextServerProxy>>>,
debug_adapter_provider_proxy: RwLock<Option<Arc<dyn ExtensionDebugAdapterProviderProxy>>>,
language_model_provider_proxy: RwLock<Option<Arc<dyn ExtensionLanguageModelProviderProxy>>>,
}
impl ExtensionHostProxy {
/// Returns the global [`ExtensionHostProxy`].
pub fn global(cx: &App) -> Arc<Self> {
GlobalExtensionHostProxy::global(cx).0.clone()
}
/// Returns the global [`ExtensionHostProxy`].
///
/// Inserts a default [`ExtensionHostProxy`] if one does not yet exist.
pub fn default_global(cx: &mut App) -> Arc<Self> {
cx.default_global::<GlobalExtensionHostProxy>().0.clone()
}
pub fn new() -> Self {
Self {
theme_proxy: RwLock::default(),
grammar_proxy: RwLock::default(),
language_proxy: RwLock::default(),
language_server_proxy: RwLock::default(),
snippet_proxy: RwLock::default(),
context_server_proxy: RwLock::default(),
debug_adapter_provider_proxy: RwLock::default(),
language_model_provider_proxy: RwLock::default(),
}
}
pub fn register_theme_proxy(&self, proxy: impl ExtensionThemeProxy) {
self.theme_proxy.write().replace(Arc::new(proxy));
}
pub fn register_grammar_proxy(&self, proxy: impl ExtensionGrammarProxy) {
self.grammar_proxy.write().replace(Arc::new(proxy));
}
pub fn register_language_proxy(&self, proxy: impl ExtensionLanguageProxy) {
self.language_proxy.write().replace(Arc::new(proxy));
}
pub fn register_language_server_proxy(&self, proxy: impl ExtensionLanguageServerProxy) {
self.language_server_proxy.write().replace(Arc::new(proxy));
}
pub fn register_snippet_proxy(&self, proxy: impl ExtensionSnippetProxy) {
self.snippet_proxy.write().replace(Arc::new(proxy));
}
pub fn register_context_server_proxy(&self, proxy: impl ExtensionContextServerProxy) {
self.context_server_proxy.write().replace(Arc::new(proxy));
}
pub fn register_debug_adapter_proxy(&self, proxy: impl ExtensionDebugAdapterProviderProxy) {
self.debug_adapter_provider_proxy
.write()
.replace(Arc::new(proxy));
}
pub fn register_language_model_provider_proxy(
&self,
proxy: impl ExtensionLanguageModelProviderProxy,
) {
self.language_model_provider_proxy
.write()
.replace(Arc::new(proxy));
}
}
pub trait ExtensionThemeProxy: Send + Sync + 'static {
fn set_extensions_loaded(&self);
fn list_theme_names(&self, theme_path: PathBuf, fs: Arc<dyn Fs>) -> Task<Result<Vec<String>>>;
fn remove_user_themes(&self, themes: Vec<SharedString>);
fn load_user_theme(&self, theme_path: PathBuf, fs: Arc<dyn Fs>) -> Task<Result<()>>;
fn reload_current_theme(&self, cx: &mut App);
fn list_icon_theme_names(
&self,
icon_theme_path: PathBuf,
fs: Arc<dyn Fs>,
) -> Task<Result<Vec<String>>>;
fn remove_icon_themes(&self, icon_themes: Vec<SharedString>);
fn load_icon_theme(
&self,
icon_theme_path: PathBuf,
icons_root_dir: PathBuf,
fs: Arc<dyn Fs>,
) -> Task<Result<()>>;
fn reload_current_icon_theme(&self, cx: &mut App);
}
impl ExtensionThemeProxy for ExtensionHostProxy {
fn set_extensions_loaded(&self) {
let Some(proxy) = self.theme_proxy.read().clone() else {
return;
};
proxy.set_extensions_loaded()
}
fn list_theme_names(&self, theme_path: PathBuf, fs: Arc<dyn Fs>) -> Task<Result<Vec<String>>> {
let Some(proxy) = self.theme_proxy.read().clone() else {
return Task::ready(Ok(Vec::new()));
};
proxy.list_theme_names(theme_path, fs)
}
fn remove_user_themes(&self, themes: Vec<SharedString>) {
let Some(proxy) = self.theme_proxy.read().clone() else {
return;
};
proxy.remove_user_themes(themes)
}
fn load_user_theme(&self, theme_path: PathBuf, fs: Arc<dyn Fs>) -> Task<Result<()>> {
let Some(proxy) = self.theme_proxy.read().clone() else {
return Task::ready(Ok(()));
};
proxy.load_user_theme(theme_path, fs)
}
fn reload_current_theme(&self, cx: &mut App) {
let Some(proxy) = self.theme_proxy.read().clone() else {
return;
};
proxy.reload_current_theme(cx)
}
fn list_icon_theme_names(
&self,
icon_theme_path: PathBuf,
fs: Arc<dyn Fs>,
) -> Task<Result<Vec<String>>> {
let Some(proxy) = self.theme_proxy.read().clone() else {
return Task::ready(Ok(Vec::new()));
};
proxy.list_icon_theme_names(icon_theme_path, fs)
}
fn remove_icon_themes(&self, icon_themes: Vec<SharedString>) {
let Some(proxy) = self.theme_proxy.read().clone() else {
return;
};
proxy.remove_icon_themes(icon_themes)
}
fn load_icon_theme(
&self,
icon_theme_path: PathBuf,
icons_root_dir: PathBuf,
fs: Arc<dyn Fs>,
) -> Task<Result<()>> {
let Some(proxy) = self.theme_proxy.read().clone() else {
return Task::ready(Ok(()));
};
proxy.load_icon_theme(icon_theme_path, icons_root_dir, fs)
}
fn reload_current_icon_theme(&self, cx: &mut App) {
let Some(proxy) = self.theme_proxy.read().clone() else {
return;
};
proxy.reload_current_icon_theme(cx)
}
}
pub trait ExtensionGrammarProxy: Send + Sync + 'static {
fn register_grammars(&self, grammars: Vec<(Arc<str>, PathBuf)>);
}
impl ExtensionGrammarProxy for ExtensionHostProxy {
#[ztracing::instrument(skip_all)]
fn register_grammars(&self, grammars: Vec<(Arc<str>, PathBuf)>) {
let Some(proxy) = self.grammar_proxy.read().clone() else {
return;
};
proxy.register_grammars(grammars)
}
}
pub trait ExtensionLanguageProxy: Send + Sync + 'static {
fn register_language(
&self,
language: LanguageName,
grammar: Option<Arc<str>>,
matcher: LanguageMatcher,
hidden: bool,
load: Arc<dyn Fn() -> Result<LoadedLanguage> + Send + Sync + 'static>,
);
fn remove_languages(
&self,
languages_to_remove: &[LanguageName],
grammars_to_remove: &[Arc<str>],
);
}
impl ExtensionLanguageProxy for ExtensionHostProxy {
#[ztracing::instrument(skip_all, fields(lang = language.0.as_str()))]
fn register_language(
&self,
language: LanguageName,
grammar: Option<Arc<str>>,
matcher: LanguageMatcher,
hidden: bool,
load: Arc<dyn Fn() -> Result<LoadedLanguage> + Send + Sync + 'static>,
) {
let Some(proxy) = self.language_proxy.read().clone() else {
return;
};
proxy.register_language(language, grammar, matcher, hidden, load)
}
fn remove_languages(
&self,
languages_to_remove: &[LanguageName],
grammars_to_remove: &[Arc<str>],
) {
let Some(proxy) = self.language_proxy.read().clone() else {
return;
};
proxy.remove_languages(languages_to_remove, grammars_to_remove)
}
}
pub trait ExtensionLanguageServerProxy: Send + Sync + 'static {
fn register_language_server(
&self,
extension: Arc<dyn Extension>,
language_server_id: LanguageServerName,
language: LanguageName,
);
fn remove_language_server(
&self,
language: &LanguageName,
language_server_id: &LanguageServerName,
cx: &mut App,
) -> Task<Result<()>>;
fn update_language_server_status(
&self,
language_server_id: LanguageServerName,
status: BinaryStatus,
);
}
impl ExtensionLanguageServerProxy for ExtensionHostProxy {
fn register_language_server(
&self,
extension: Arc<dyn Extension>,
language_server_id: LanguageServerName,
language: LanguageName,
) {
let Some(proxy) = self.language_server_proxy.read().clone() else {
return;
};
proxy.register_language_server(extension, language_server_id, language)
}
fn remove_language_server(
&self,
language: &LanguageName,
language_server_id: &LanguageServerName,
cx: &mut App,
) -> Task<Result<()>> {
let Some(proxy) = self.language_server_proxy.read().clone() else {
return Task::ready(Ok(()));
};
proxy.remove_language_server(language, language_server_id, cx)
}
fn update_language_server_status(
&self,
language_server_id: LanguageServerName,
status: BinaryStatus,
) {
let Some(proxy) = self.language_server_proxy.read().clone() else {
return;
};
proxy.update_language_server_status(language_server_id, status)
}
}
pub trait ExtensionSnippetProxy: Send + Sync + 'static {
fn register_snippet(&self, path: &PathBuf, snippet_contents: &str) -> Result<()>;
}
impl ExtensionSnippetProxy for ExtensionHostProxy {
fn register_snippet(&self, path: &PathBuf, snippet_contents: &str) -> Result<()> {
let Some(proxy) = self.snippet_proxy.read().clone() else {
return Ok(());
};
proxy.register_snippet(path, snippet_contents)
}
}
pub trait ExtensionContextServerProxy: Send + Sync + 'static {
fn register_context_server(
&self,
extension: Arc<dyn Extension>,
server_id: Arc<str>,
cx: &mut App,
);
fn unregister_context_server(&self, server_id: Arc<str>, cx: &mut App);
}
impl ExtensionContextServerProxy for ExtensionHostProxy {
fn register_context_server(
&self,
extension: Arc<dyn Extension>,
server_id: Arc<str>,
cx: &mut App,
) {
let Some(proxy) = self.context_server_proxy.read().clone() else {
return;
};
proxy.register_context_server(extension, server_id, cx)
}
fn unregister_context_server(&self, server_id: Arc<str>, cx: &mut App) {
let Some(proxy) = self.context_server_proxy.read().clone() else {
return;
};
proxy.unregister_context_server(server_id, cx)
}
}
pub trait ExtensionDebugAdapterProviderProxy: Send + Sync + 'static {
fn register_debug_adapter(
&self,
extension: Arc<dyn Extension>,
debug_adapter_name: Arc<str>,
schema_path: &Path,
);
fn register_debug_locator(&self, extension: Arc<dyn Extension>, locator_name: Arc<str>);
fn unregister_debug_adapter(&self, debug_adapter_name: Arc<str>);
fn unregister_debug_locator(&self, locator_name: Arc<str>);
}
impl ExtensionDebugAdapterProviderProxy for ExtensionHostProxy {
fn register_debug_adapter(
&self,
extension: Arc<dyn Extension>,
debug_adapter_name: Arc<str>,
schema_path: &Path,
) {
let Some(proxy) = self.debug_adapter_provider_proxy.read().clone() else {
return;
};
proxy.register_debug_adapter(extension, debug_adapter_name, schema_path)
}
fn register_debug_locator(&self, extension: Arc<dyn Extension>, locator_name: Arc<str>) {
let Some(proxy) = self.debug_adapter_provider_proxy.read().clone() else {
return;
};
proxy.register_debug_locator(extension, locator_name)
}
fn unregister_debug_adapter(&self, debug_adapter_name: Arc<str>) {
let Some(proxy) = self.debug_adapter_provider_proxy.read().clone() else {
return;
};
proxy.unregister_debug_adapter(debug_adapter_name)
}
fn unregister_debug_locator(&self, locator_name: Arc<str>) {
let Some(proxy) = self.debug_adapter_provider_proxy.read().clone() else {
return;
};
proxy.unregister_debug_locator(locator_name)
}
}
pub trait ExtensionLanguageModelProviderProxy: Send + Sync + 'static {
fn register_language_model_provider(
&self,
provider_id: Arc<str>,
register_fn: LanguageModelProviderRegistration,
cx: &mut App,
);
fn unregister_language_model_provider(&self, provider_id: Arc<str>, cx: &mut App);
}
impl ExtensionLanguageModelProviderProxy for ExtensionHostProxy {
fn register_language_model_provider(
&self,
provider_id: Arc<str>,
register_fn: LanguageModelProviderRegistration,
cx: &mut App,
) {
let Some(proxy) = self.language_model_provider_proxy.read().clone() else {
return;
};
proxy.register_language_model_provider(provider_id, register_fn, cx)
}
fn unregister_language_model_provider(&self, provider_id: Arc<str>, cx: &mut App) {
let Some(proxy) = self.language_model_provider_proxy.read().clone() else {
return;
};
proxy.unregister_language_model_provider(provider_id, cx)
}
}

View File

@@ -0,0 +1,619 @@
use std::ffi::OsStr;
use std::fmt;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use anyhow::{Context as _, Result, anyhow, bail};
use cloud_api_types::ExtensionProvides;
use collections::{BTreeMap, BTreeSet, HashMap};
use fs::Fs;
use language::LanguageName;
use lsp::LanguageServerName;
use semver::Version;
use serde::{Deserialize, Serialize};
use util::rel_path::{PathExt, RelPathBuf};
use crate::ExtensionCapability;
/// This is the old version of the extension manifest, from when it was `extension.json`.
#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
pub struct OldExtensionManifest {
pub name: String,
pub version: Arc<str>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub repository: Option<String>,
#[serde(default)]
pub authors: Vec<String>,
#[serde(default)]
pub themes: BTreeMap<Arc<str>, RelPathBuf>,
#[serde(default)]
pub languages: BTreeMap<Arc<str>, RelPathBuf>,
#[serde(default)]
pub grammars: BTreeMap<Arc<str>, RelPathBuf>,
}
/// The schema version of the [`ExtensionManifest`].
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy, Serialize, Deserialize)]
pub struct SchemaVersion(pub i32);
impl fmt::Display for SchemaVersion {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
impl SchemaVersion {
pub const ZERO: Self = Self(0);
pub fn is_v0(&self) -> bool {
self == &Self::ZERO
}
}
// TODO: We should change this to just always be a Vec<PathBuf> once we bump the
// extension.toml schema version to 2
#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum ExtensionSnippets {
Single(PathBuf),
Multiple(Vec<PathBuf>),
}
impl ExtensionSnippets {
pub fn paths(&self) -> impl Iterator<Item = &PathBuf> {
match self {
ExtensionSnippets::Single(path) => std::slice::from_ref(path).iter(),
ExtensionSnippets::Multiple(paths) => paths.iter(),
}
}
}
impl From<&str> for ExtensionSnippets {
fn from(value: &str) -> Self {
ExtensionSnippets::Single(value.into())
}
}
#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
pub struct ExtensionManifest {
pub id: Arc<str>,
pub name: String,
pub version: Arc<str>,
pub schema_version: SchemaVersion,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub repository: Option<String>,
#[serde(default)]
pub authors: Vec<String>,
#[serde(default)]
pub lib: LibManifestEntry,
#[serde(default)]
pub themes: Vec<RelPathBuf>,
#[serde(default)]
pub icon_themes: Vec<RelPathBuf>,
#[serde(default)]
pub languages: Vec<RelPathBuf>,
#[serde(default)]
pub grammars: BTreeMap<Arc<str>, GrammarManifestEntry>,
#[serde(default)]
pub language_servers: BTreeMap<LanguageServerName, LanguageServerManifestEntry>,
#[serde(default)]
pub context_servers: BTreeMap<Arc<str>, ContextServerManifestEntry>,
#[serde(default)]
pub agent_servers: BTreeMap<Arc<str>, AgentServerManifestEntry>,
#[serde(default)]
pub slash_commands: BTreeMap<Arc<str>, SlashCommandManifestEntry>,
#[serde(default)]
pub snippets: Option<ExtensionSnippets>,
#[serde(default)]
pub capabilities: Vec<ExtensionCapability>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub debug_adapters: BTreeMap<Arc<str>, DebugAdapterManifestEntry>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub debug_locators: BTreeMap<Arc<str>, DebugLocatorManifestEntry>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub language_model_providers: BTreeMap<Arc<str>, LanguageModelProviderManifestEntry>,
}
impl ExtensionManifest {
/// Returns the set of features provided by the extension.
pub fn provides(&self) -> BTreeSet<ExtensionProvides> {
let mut provides = BTreeSet::default();
if !self.themes.is_empty() {
provides.insert(ExtensionProvides::Themes);
}
if !self.icon_themes.is_empty() {
provides.insert(ExtensionProvides::IconThemes);
}
if !self.languages.is_empty() {
provides.insert(ExtensionProvides::Languages);
}
if !self.grammars.is_empty() {
provides.insert(ExtensionProvides::Grammars);
}
if !self.language_servers.is_empty() {
provides.insert(ExtensionProvides::LanguageServers);
}
if !self.context_servers.is_empty() {
provides.insert(ExtensionProvides::ContextServers);
}
if !self.agent_servers.is_empty() {
provides.insert(ExtensionProvides::AgentServers);
}
if self.snippets.is_some() {
provides.insert(ExtensionProvides::Snippets);
}
if !self.debug_adapters.is_empty() {
provides.insert(ExtensionProvides::DebugAdapters);
}
provides
}
pub fn allow_exec(
&self,
desired_command: &str,
desired_args: &[impl AsRef<str> + std::fmt::Debug],
) -> Result<()> {
let is_allowed = self.capabilities.iter().any(|capability| match capability {
ExtensionCapability::ProcessExec(capability) => {
capability.allows(desired_command, desired_args)
}
_ => false,
});
if !is_allowed {
bail!(
"capability for process:exec {desired_command} {desired_args:?} was not listed in the extension manifest",
);
}
Ok(())
}
pub fn allow_remote_load(&self) -> bool {
!self.language_servers.is_empty()
|| !self.debug_adapters.is_empty()
|| !self.debug_locators.is_empty()
}
}
pub fn build_debug_adapter_schema_path(
adapter_name: &Arc<str>,
meta: &DebugAdapterManifestEntry,
) -> anyhow::Result<RelPathBuf> {
match &meta.schema_path {
Some(path) => Ok(path.clone()),
None => Path::new("debug_adapter_schemas")
.join(Path::new(adapter_name.as_ref()).with_extension("json"))
.to_rel_path_buf(),
}
}
#[derive(Clone, Default, PartialEq, Eq, Debug, Deserialize, Serialize)]
pub struct LibManifestEntry {
pub kind: Option<ExtensionLibraryKind>,
pub version: Option<Version>,
}
#[derive(Clone, PartialEq, Eq, Debug, Deserialize, Serialize)]
pub struct AgentServerManifestEntry {
/// Display name for the agent (shown in menus).
pub name: String,
/// Environment variables to set when launching the agent server.
#[serde(default)]
pub env: HashMap<String, String>,
/// Optional icon path (relative to extension root, e.g., "ai.svg").
/// Should be a small SVG icon for display in menus.
#[serde(default)]
pub icon: Option<String>,
/// Per-target configuration for archive-based installation.
/// The key format is "{os}-{arch}" where:
/// - os: "darwin" (macOS), "linux", "windows"
/// - arch: "aarch64" (arm64), "x86_64"
///
/// Example:
/// ```toml
/// [agent_servers.myagent.targets.darwin-aarch64]
/// archive = "https://example.com/myagent-darwin-arm64.zip"
/// cmd = "./myagent"
/// args = ["--serve"]
/// sha256 = "abc123..." # optional
/// ```
///
/// For Node.js-based agents, you can use "node" as the cmd to automatically
/// use Zed's managed Node.js runtime instead of relying on the user's PATH:
/// ```toml
/// [agent_servers.nodeagent.targets.darwin-aarch64]
/// archive = "https://example.com/nodeagent.zip"
/// cmd = "node"
/// args = ["index.js", "--port", "3000"]
/// ```
///
/// Note: All commands are executed with the archive extraction directory as the
/// working directory, so relative paths in args (like "index.js") will resolve
/// relative to the extracted archive contents.
pub targets: HashMap<String, TargetConfig>,
}
#[derive(Clone, PartialEq, Eq, Debug, Deserialize, Serialize)]
pub struct TargetConfig {
/// URL to download the archive from (e.g., "https://github.com/owner/repo/releases/download/v1.0.0/myagent-darwin-arm64.zip")
pub archive: String,
/// Command to run (e.g., "./myagent" or "./myagent.exe")
pub cmd: String,
/// Command-line arguments to pass to the agent server.
#[serde(default)]
pub args: Vec<String>,
/// Optional SHA-256 hash of the archive for verification.
/// If not provided and the URL is a GitHub release, we'll attempt to fetch it from GitHub.
#[serde(default)]
pub sha256: Option<String>,
/// Environment variables to set when launching the agent server.
/// These target-specific env vars will override any env vars set at the agent level.
#[serde(default)]
pub env: HashMap<String, String>,
}
impl TargetConfig {
pub fn from_proto(proto: proto::ExternalExtensionAgentTarget) -> Self {
Self {
archive: proto.archive,
cmd: proto.cmd,
args: proto.args,
sha256: proto.sha256,
env: proto.env.into_iter().collect(),
}
}
pub fn to_proto(&self) -> proto::ExternalExtensionAgentTarget {
proto::ExternalExtensionAgentTarget {
archive: self.archive.clone(),
cmd: self.cmd.clone(),
args: self.args.clone(),
sha256: self.sha256.clone(),
env: self
.env
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect(),
}
}
}
#[derive(Clone, PartialEq, Eq, Debug, Deserialize, Serialize)]
pub enum ExtensionLibraryKind {
Rust,
}
#[derive(Clone, Default, PartialEq, Eq, Debug, Deserialize, Serialize)]
pub struct GrammarManifestEntry {
pub repository: String,
#[serde(alias = "commit")]
pub rev: String,
#[serde(default)]
pub path: Option<String>,
}
#[derive(Clone, Default, PartialEq, Eq, Debug, Deserialize, Serialize)]
pub struct LanguageServerManifestEntry {
/// Deprecated in favor of `languages`.
#[serde(default)]
language: Option<LanguageName>,
/// The list of languages this language server should work with.
#[serde(default)]
languages: Vec<LanguageName>,
#[serde(default)]
pub language_ids: HashMap<LanguageName, String>,
#[serde(default)]
pub code_action_kinds: Option<Vec<lsp::CodeActionKind>>,
}
impl LanguageServerManifestEntry {
/// Returns the list of languages for the language server.
///
/// Prefer this over accessing the `language` or `languages` fields directly,
/// as we currently support both.
///
/// We can replace this with just field access for the `languages` field once
/// we have removed `language`.
pub fn languages(&self) -> impl IntoIterator<Item = LanguageName> + '_ {
let language = if self.languages.is_empty() {
self.language.clone()
} else {
None
};
self.languages.iter().cloned().chain(language)
}
}
#[derive(Clone, PartialEq, Eq, Debug, Deserialize, Serialize)]
pub struct ContextServerManifestEntry {}
#[derive(Clone, PartialEq, Eq, Debug, Deserialize, Serialize)]
pub struct SlashCommandManifestEntry {
pub description: String,
pub requires_argument: bool,
}
#[derive(Clone, PartialEq, Eq, Debug, Deserialize, Serialize)]
pub struct DebugAdapterManifestEntry {
pub schema_path: Option<RelPathBuf>,
}
#[derive(Clone, PartialEq, Eq, Debug, Deserialize, Serialize)]
pub struct DebugLocatorManifestEntry {}
/// Manifest entry for a language model provider.
#[derive(Clone, PartialEq, Eq, Debug, Deserialize, Serialize)]
pub struct LanguageModelProviderManifestEntry {
/// Display name for the provider.
pub name: String,
/// Path to an SVG icon file relative to the extension root (e.g., "icons/provider.svg").
#[serde(default)]
pub icon: Option<String>,
}
impl ExtensionManifest {
pub async fn load(fs: Arc<dyn Fs>, extension_dir: &Path) -> Result<Self> {
let extension_name = extension_dir
.file_name()
.and_then(OsStr::to_str)
.context("invalid extension name")?;
let extension_manifest_path = extension_dir.join("extension.toml");
if fs.is_file(&extension_manifest_path).await {
let manifest_content = fs.load(&extension_manifest_path).await.with_context(|| {
format!("loading {extension_name} extension.toml, {extension_manifest_path:?}")
})?;
toml::from_str(&manifest_content).map_err(|err| {
anyhow!("Invalid extension.toml for extension {extension_name}:\n{err}")
})
} else if let extension_manifest_path = extension_manifest_path.with_extension("json")
&& fs.is_file(&extension_manifest_path).await
{
let manifest_content = fs.load(&extension_manifest_path).await.with_context(|| {
format!("loading {extension_name} extension.json, {extension_manifest_path:?}")
})?;
serde_json::from_str::<OldExtensionManifest>(&manifest_content)
.with_context(|| format!("invalid extension.json for extension {extension_name}"))
.map(|manifest_json| manifest_from_old_manifest(manifest_json, extension_name))
} else {
anyhow::bail!("No extension manifest found for extension {extension_name}")
}
}
}
fn manifest_from_old_manifest(
manifest_json: OldExtensionManifest,
extension_id: &str,
) -> ExtensionManifest {
ExtensionManifest {
id: extension_id.into(),
name: manifest_json.name,
version: manifest_json.version,
description: manifest_json.description,
repository: manifest_json.repository,
authors: manifest_json.authors,
schema_version: SchemaVersion::ZERO,
lib: Default::default(),
themes: {
let mut themes = manifest_json.themes.into_values().collect::<Vec<_>>();
themes.sort();
themes.dedup();
themes
},
icon_themes: Vec::new(),
languages: {
let mut languages = manifest_json.languages.into_values().collect::<Vec<_>>();
languages.sort();
languages.dedup();
languages
},
grammars: manifest_json
.grammars
.into_keys()
.map(|grammar_name| (grammar_name, Default::default()))
.collect(),
language_servers: Default::default(),
context_servers: BTreeMap::default(),
agent_servers: BTreeMap::default(),
slash_commands: BTreeMap::default(),
snippets: None,
capabilities: Vec::new(),
debug_adapters: Default::default(),
debug_locators: Default::default(),
language_model_providers: Default::default(),
}
}
#[cfg(test)]
mod tests {
use indoc::indoc;
use pretty_assertions::assert_eq;
use util::rel_path::rel_path_buf;
use crate::ProcessExecCapability;
use super::*;
fn extension_manifest() -> ExtensionManifest {
ExtensionManifest {
id: "test".into(),
name: "Test".to_string(),
version: "1.0.0".into(),
schema_version: SchemaVersion::ZERO,
description: None,
repository: None,
authors: vec![],
lib: Default::default(),
themes: vec![],
icon_themes: vec![],
languages: vec![],
grammars: BTreeMap::default(),
language_servers: BTreeMap::default(),
context_servers: BTreeMap::default(),
agent_servers: BTreeMap::default(),
slash_commands: BTreeMap::default(),
snippets: None,
capabilities: vec![],
debug_adapters: Default::default(),
debug_locators: Default::default(),
language_model_providers: BTreeMap::default(),
}
}
#[test]
fn test_build_adapter_schema_path_with_schema_path() {
let adapter_name = Arc::from("my_adapter");
let entry = DebugAdapterManifestEntry {
schema_path: Some(rel_path_buf("foo/bar")),
};
let path = build_debug_adapter_schema_path(&adapter_name, &entry).unwrap();
assert_eq!(path, rel_path_buf("foo/bar"));
}
#[test]
fn test_build_adapter_schema_path_without_schema_path() {
let adapter_name = Arc::from("my_adapter");
let entry = DebugAdapterManifestEntry { schema_path: None };
let path = build_debug_adapter_schema_path(&adapter_name, &entry).unwrap();
assert_eq!(path, rel_path_buf("debug_adapter_schemas/my_adapter.json"));
}
#[test]
fn test_allow_exec_exact_match() {
let manifest = ExtensionManifest {
capabilities: vec![ExtensionCapability::ProcessExec(ProcessExecCapability {
command: "ls".to_string(),
args: vec!["-la".to_string()],
})],
..extension_manifest()
};
assert!(manifest.allow_exec("ls", &["-la"]).is_ok());
assert!(manifest.allow_exec("ls", &["-l"]).is_err());
assert!(manifest.allow_exec("pwd", &[] as &[&str]).is_err());
}
#[test]
fn test_allow_exec_wildcard_arg() {
let manifest = ExtensionManifest {
capabilities: vec![ExtensionCapability::ProcessExec(ProcessExecCapability {
command: "git".to_string(),
args: vec!["*".to_string()],
})],
..extension_manifest()
};
assert!(manifest.allow_exec("git", &["status"]).is_ok());
assert!(manifest.allow_exec("git", &["commit"]).is_ok());
assert!(manifest.allow_exec("git", &["status", "-s"]).is_err()); // too many args
assert!(manifest.allow_exec("npm", &["install"]).is_err()); // wrong command
}
#[test]
fn test_allow_exec_double_wildcard() {
let manifest = ExtensionManifest {
capabilities: vec![ExtensionCapability::ProcessExec(ProcessExecCapability {
command: "cargo".to_string(),
args: vec!["test".to_string(), "**".to_string()],
})],
..extension_manifest()
};
assert!(manifest.allow_exec("cargo", &["test"]).is_ok());
assert!(manifest.allow_exec("cargo", &["test", "--all"]).is_ok());
assert!(
manifest
.allow_exec("cargo", &["test", "--all", "--no-fail-fast"])
.is_ok()
);
assert!(manifest.allow_exec("cargo", &["build"]).is_err()); // wrong first arg
}
#[test]
fn test_allow_exec_mixed_wildcards() {
let manifest = ExtensionManifest {
capabilities: vec![ExtensionCapability::ProcessExec(ProcessExecCapability {
command: "docker".to_string(),
args: vec!["run".to_string(), "*".to_string(), "**".to_string()],
})],
..extension_manifest()
};
assert!(manifest.allow_exec("docker", &["run", "nginx"]).is_ok());
assert!(manifest.allow_exec("docker", &["run"]).is_err());
assert!(
manifest
.allow_exec("docker", &["run", "ubuntu", "bash"])
.is_ok()
);
assert!(
manifest
.allow_exec("docker", &["run", "alpine", "sh", "-c", "echo hello"])
.is_ok()
);
assert!(manifest.allow_exec("docker", &["ps"]).is_err()); // wrong first arg
}
#[test]
#[cfg(target_os = "windows")]
fn test_deserialize_manifest_with_windows_separators() {
let content = indoc! {r#"
id = "test-manifest"
name = "Test Manifest"
version = "0.0.1"
schema_version = 0
languages = ["foo\\bar"]
"#};
let manifest: ExtensionManifest = toml::from_str(&content).expect("manifest should parse");
assert_eq!(manifest.languages, vec![rel_path_buf("foo/bar")]);
}
#[test]
fn parse_manifest_with_agent_server_archive_launcher() {
let toml_src = indoc! {r#"
id = "example.agent-server-ext"
name = "Agent Server Example"
version = "1.0.0"
schema_version = 0
[agent_servers.foo]
name = "Foo Agent"
[agent_servers.foo.targets.linux-x86_64]
archive = "https://example.com/agent-linux-x64.tar.gz"
cmd = "./agent"
args = ["--serve"]
"#};
let manifest: ExtensionManifest = toml::from_str(toml_src).expect("manifest should parse");
assert_eq!(manifest.id.as_ref(), "example.agent-server-ext");
assert!(manifest.agent_servers.contains_key("foo"));
let entry = manifest.agent_servers.get("foo").unwrap();
assert!(entry.targets.contains_key("linux-x86_64"));
let target = entry.targets.get("linux-x86_64").unwrap();
assert_eq!(target.archive, "https://example.com/agent-linux-x64.tar.gz");
assert_eq!(target.cmd, "./agent");
assert_eq!(target.args, vec!["--serve"]);
}
}

View File

@@ -0,0 +1,71 @@
mod context_server;
mod dap;
mod lsp;
mod slash_command;
use std::{ops::Range, path::PathBuf};
use util::redact::should_redact;
pub use context_server::*;
pub use dap::*;
pub use lsp::*;
pub use slash_command::*;
/// A list of environment variables.
pub type EnvVars = Vec<(String, String)>;
/// A command.
pub struct Command {
/// The command to execute.
pub command: PathBuf,
/// The arguments to pass to the command.
pub args: Vec<String>,
/// The environment variables to set for the command.
pub env: EnvVars,
}
impl std::fmt::Debug for Command {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let filtered_env = self
.env
.iter()
.map(|(k, v)| (k, if should_redact(k) { "[REDACTED]" } else { v }))
.collect::<Vec<_>>();
f.debug_struct("Command")
.field("command", &self.command)
.field("args", &self.args)
.field("env", &filtered_env)
.finish()
}
}
/// A label containing some code.
#[derive(Debug, Clone)]
pub struct CodeLabel {
/// The source code to parse with Tree-sitter.
pub code: String,
/// The spans to display in the label.
pub spans: Vec<CodeLabelSpan>,
/// The range of the displayed label to include when filtering.
pub filter_range: Range<usize>,
}
/// A span within a code label.
#[derive(Debug, Clone)]
pub enum CodeLabelSpan {
/// A range into the parsed code.
CodeRange(Range<usize>),
/// A span containing a code literal.
Literal(CodeLabelSpanLiteral),
}
/// A span containing a code literal.
#[derive(Debug, Clone)]
pub struct CodeLabelSpanLiteral {
/// The literal text.
pub text: String,
/// The name of the highlight to use for this literal.
pub highlight_name: Option<String>,
}

View File

@@ -0,0 +1,10 @@
/// Configuration for context server setup and installation.
#[derive(Debug, Clone)]
pub struct ContextServerConfiguration {
/// Installation instructions in Markdown format.
pub installation_instructions: String,
/// JSON schema for settings validation.
pub settings_schema: serde_json::Value,
/// Default settings template.
pub default_settings: String,
}

View File

@@ -0,0 +1,8 @@
pub use dap::{
StartDebuggingRequestArguments, StartDebuggingRequestArgumentsRequest,
adapters::{DebugAdapterBinary, DebugTaskDefinition, TcpArguments},
};
pub use task::{
AttachRequest, BuildTaskDefinition, DebugRequest, DebugScenario, LaunchRequest,
TaskTemplate as BuildTaskTemplate, TcpArgumentsTemplate,
};

View File

@@ -0,0 +1,97 @@
use std::option::Option;
/// An LSP completion.
#[derive(Debug, Clone)]
pub struct Completion {
pub label: String,
pub label_details: Option<CompletionLabelDetails>,
pub detail: Option<String>,
pub kind: Option<CompletionKind>,
pub insert_text_format: Option<InsertTextFormat>,
}
/// The kind of an LSP completion.
#[derive(Debug, Clone, Copy)]
pub enum CompletionKind {
Text,
Method,
Function,
Constructor,
Field,
Variable,
Class,
Interface,
Module,
Property,
Unit,
Value,
Enum,
Keyword,
Snippet,
Color,
File,
Reference,
Folder,
EnumMember,
Constant,
Struct,
Event,
Operator,
TypeParameter,
Other(i32),
}
/// Label details for an LSP completion.
#[derive(Debug, Clone)]
pub struct CompletionLabelDetails {
pub detail: Option<String>,
pub description: Option<String>,
}
/// Defines how to interpret the insert text in a completion item.
#[derive(Debug, Clone, Copy)]
pub enum InsertTextFormat {
PlainText,
Snippet,
Other(i32),
}
/// An LSP symbol.
#[derive(Debug, Clone)]
pub struct Symbol {
pub kind: SymbolKind,
pub name: String,
pub container_name: Option<String>,
}
/// The kind of an LSP symbol.
#[derive(Debug, Clone, Copy)]
pub enum SymbolKind {
File,
Module,
Namespace,
Package,
Class,
Method,
Property,
Field,
Constructor,
Enum,
Interface,
Function,
Variable,
Constant,
String,
Number,
Boolean,
Array,
Object,
Key,
Null,
EnumMember,
Struct,
Event,
Operator,
TypeParameter,
Other(i32),
}

View File

@@ -0,0 +1,43 @@
use std::ops::Range;
/// A slash command for use in the Assistant.
#[derive(Debug, Clone)]
pub struct SlashCommand {
/// The name of the slash command.
pub name: String,
/// The description of the slash command.
pub description: String,
/// The tooltip text to display for the run button.
pub tooltip_text: String,
/// Whether this slash command requires an argument.
pub requires_argument: bool,
}
/// The output of a slash command.
#[derive(Debug, Clone)]
pub struct SlashCommandOutput {
/// The text produced by the slash command.
pub text: String,
/// The list of sections to show in the slash command placeholder.
pub sections: Vec<SlashCommandOutputSection>,
}
/// A section in the slash command output.
#[derive(Debug, Clone)]
pub struct SlashCommandOutputSection {
/// The range this section occupies.
pub range: Range<usize>,
/// The label to display in the placeholder for this section.
pub label: String,
}
/// A completion for a slash command argument.
#[derive(Debug, Clone)]
pub struct SlashCommandArgumentCompletion {
/// The label to display for this completion.
pub label: String,
/// The new text that should be inserted into the command when this completion is accepted.
pub new_text: String,
/// Whether the command should be run when accepting this completion.
pub run_command: bool,
}