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
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:
50
crates/extension/Cargo.toml
Normal file
50
crates/extension/Cargo.toml
Normal 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"]
|
||||
1
crates/extension/LICENSE-GPL
Symbolic link
1
crates/extension/LICENSE-GPL
Symbolic link
@@ -0,0 +1 @@
|
||||
../../LICENSE-GPL
|
||||
20
crates/extension/src/capabilities.rs
Normal file
20
crates/extension/src/capabilities.rs
Normal 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),
|
||||
}
|
||||
121
crates/extension/src/capabilities/download_file_capability.rs
Normal file
121
crates/extension/src/capabilities/download_file_capability.rs
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
116
crates/extension/src/capabilities/process_exec_capability.rs
Normal file
116
crates/extension/src/capabilities/process_exec_capability.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
221
crates/extension/src/extension.rs
Normal file
221
crates/extension/src/extension.rs
Normal 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
|
||||
}
|
||||
}
|
||||
830
crates/extension/src/extension_builder.rs
Normal file
830
crates/extension/src/extension_builder.rs
Normal 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()
|
||||
))
|
||||
)
|
||||
}
|
||||
}
|
||||
43
crates/extension/src/extension_events.rs
Normal file
43
crates/extension/src/extension_events.rs
Normal 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 {}
|
||||
468
crates/extension/src/extension_host_proxy.rs
Normal file
468
crates/extension/src/extension_host_proxy.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
619
crates/extension/src/extension_manifest.rs
Normal file
619
crates/extension/src/extension_manifest.rs
Normal 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"]);
|
||||
}
|
||||
}
|
||||
71
crates/extension/src/types.rs
Normal file
71
crates/extension/src/types.rs
Normal 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>,
|
||||
}
|
||||
10
crates/extension/src/types/context_server.rs
Normal file
10
crates/extension/src/types/context_server.rs
Normal 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,
|
||||
}
|
||||
8
crates/extension/src/types/dap.rs
Normal file
8
crates/extension/src/types/dap.rs
Normal 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,
|
||||
};
|
||||
97
crates/extension/src/types/lsp.rs
Normal file
97
crates/extension/src/types/lsp.rs
Normal 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),
|
||||
}
|
||||
43
crates/extension/src/types/slash_command.rs
Normal file
43
crates/extension/src/types/slash_command.rs
Normal 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,
|
||||
}
|
||||
Reference in New Issue
Block a user