Add a schema to extensions, to prevent installing extensions on too old of a Zed version (#9599)

Release Notes:

- N/A

---------

Co-authored-by: Marshall <marshall@zed.dev>
This commit is contained in:
Max Brunsfeld 2024-03-20 14:33:26 -07:00 committed by GitHub
parent b1feeb9f29
commit 585e8671e3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 165 additions and 44 deletions

3
Cargo.lock generated
View File

@ -3482,6 +3482,7 @@ dependencies = [
"settings", "settings",
"theme", "theme",
"toml 0.8.10", "toml 0.8.10",
"url",
"util", "util",
"wasm-encoder", "wasm-encoder",
"wasmparser", "wasmparser",
@ -12630,7 +12631,7 @@ dependencies = [
[[package]] [[package]]
name = "zed_extension_api" name = "zed_extension_api"
version = "0.1.0" version = "0.0.1"
dependencies = [ dependencies = [
"wit-bindgen", "wit-bindgen",
] ]

View File

@ -470,7 +470,11 @@ impl Telemetry {
let request = http::Request::builder() let request = http::Request::builder()
.method(Method::POST) .method(Method::POST)
.uri(this.http_client.build_zed_api_url("/telemetry/events")) .uri(
this.http_client
.build_zed_api_url("/telemetry/events", &[])?
.as_ref(),
)
.header("Content-Type", "text/plain") .header("Content-Type", "text/plain")
.header("x-zed-checksum", checksum) .header("x-zed-checksum", checksum)
.body(json_bytes.into()); .body(json_bytes.into());

View File

@ -373,6 +373,7 @@ CREATE TABLE extension_versions (
authors TEXT NOT NULL, authors TEXT NOT NULL,
repository TEXT NOT NULL, repository TEXT NOT NULL,
description TEXT NOT NULL, description TEXT NOT NULL,
schema_version INTEGER NOT NULL DEFAULT 0,
download_count INTEGER NOT NULL DEFAULT 0, download_count INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (extension_id, version) PRIMARY KEY (extension_id, version)
); );

View File

@ -0,0 +1,2 @@
-- Add migration script here
ALTER TABLE extension_versions ADD COLUMN schema_version INTEGER NOT NULL DEFAULT 0;

View File

@ -12,6 +12,7 @@ use axum::{
Extension, Json, Router, Extension, Json, Router,
}; };
use collections::HashMap; use collections::HashMap;
use rpc::ExtensionApiManifest;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::{sync::Arc, time::Duration}; use std::{sync::Arc, time::Duration};
use time::PrimitiveDateTime; use time::PrimitiveDateTime;
@ -33,6 +34,8 @@ pub fn router() -> Router {
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct GetExtensionsParams { struct GetExtensionsParams {
filter: Option<String>, filter: Option<String>,
#[serde(default)]
max_schema_version: i32,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@ -51,20 +54,14 @@ struct GetExtensionsResponse {
pub data: Vec<ExtensionMetadata>, pub data: Vec<ExtensionMetadata>,
} }
#[derive(Deserialize)]
struct ExtensionManifest {
name: String,
version: String,
description: Option<String>,
authors: Vec<String>,
repository: String,
}
async fn get_extensions( async fn get_extensions(
Extension(app): Extension<Arc<AppState>>, Extension(app): Extension<Arc<AppState>>,
Query(params): Query<GetExtensionsParams>, Query(params): Query<GetExtensionsParams>,
) -> Result<Json<GetExtensionsResponse>> { ) -> Result<Json<GetExtensionsResponse>> {
let extensions = app.db.get_extensions(params.filter.as_deref(), 500).await?; let extensions = app
.db
.get_extensions(params.filter.as_deref(), params.max_schema_version, 500)
.await?;
Ok(Json(GetExtensionsResponse { data: extensions })) Ok(Json(GetExtensionsResponse { data: extensions }))
} }
@ -267,7 +264,7 @@ async fn fetch_extension_manifest(
})? })?
.to_vec(); .to_vec();
let manifest = let manifest =
serde_json::from_slice::<ExtensionManifest>(&manifest_bytes).with_context(|| { serde_json::from_slice::<ExtensionApiManifest>(&manifest_bytes).with_context(|| {
format!( format!(
"invalid manifest for extension {extension_id} version {version}: {}", "invalid manifest for extension {extension_id} version {version}: {}",
String::from_utf8_lossy(&manifest_bytes) String::from_utf8_lossy(&manifest_bytes)
@ -287,6 +284,7 @@ async fn fetch_extension_manifest(
description: manifest.description.unwrap_or_default(), description: manifest.description.unwrap_or_default(),
authors: manifest.authors, authors: manifest.authors,
repository: manifest.repository, repository: manifest.repository,
schema_version: manifest.schema_version.unwrap_or(0),
published_at, published_at,
}) })
} }

View File

@ -731,6 +731,7 @@ pub struct NewExtensionVersion {
pub description: String, pub description: String,
pub authors: Vec<String>, pub authors: Vec<String>,
pub repository: String, pub repository: String,
pub schema_version: i32,
pub published_at: PrimitiveDateTime, pub published_at: PrimitiveDateTime,
} }

View File

@ -4,27 +4,28 @@ impl Database {
pub async fn get_extensions( pub async fn get_extensions(
&self, &self,
filter: Option<&str>, filter: Option<&str>,
max_schema_version: i32,
limit: usize, limit: usize,
) -> Result<Vec<ExtensionMetadata>> { ) -> Result<Vec<ExtensionMetadata>> {
self.transaction(|tx| async move { self.transaction(|tx| async move {
let mut condition = Condition::all(); let mut condition = Condition::all().add(
extension::Column::LatestVersion
.into_expr()
.eq(extension_version::Column::Version.into_expr()),
);
if let Some(filter) = filter { if let Some(filter) = filter {
let fuzzy_name_filter = Self::fuzzy_like_string(filter); let fuzzy_name_filter = Self::fuzzy_like_string(filter);
condition = condition.add(Expr::cust_with_expr("name ILIKE $1", fuzzy_name_filter)); condition = condition.add(Expr::cust_with_expr("name ILIKE $1", fuzzy_name_filter));
} }
let extensions = extension::Entity::find() let extensions = extension::Entity::find()
.inner_join(extension_version::Entity)
.select_also(extension_version::Entity)
.filter(condition) .filter(condition)
.filter(extension_version::Column::SchemaVersion.lte(max_schema_version))
.order_by_desc(extension::Column::TotalDownloadCount) .order_by_desc(extension::Column::TotalDownloadCount)
.order_by_asc(extension::Column::Name) .order_by_asc(extension::Column::Name)
.limit(Some(limit as u64)) .limit(Some(limit as u64))
.filter(
extension::Column::LatestVersion
.into_expr()
.eq(extension_version::Column::Version.into_expr()),
)
.inner_join(extension_version::Entity)
.select_also(extension_version::Entity)
.all(&*tx) .all(&*tx)
.await?; .await?;
@ -170,6 +171,7 @@ impl Database {
authors: ActiveValue::Set(version.authors.join(", ")), authors: ActiveValue::Set(version.authors.join(", ")),
repository: ActiveValue::Set(version.repository.clone()), repository: ActiveValue::Set(version.repository.clone()),
description: ActiveValue::Set(version.description.clone()), description: ActiveValue::Set(version.description.clone()),
schema_version: ActiveValue::Set(version.schema_version),
download_count: ActiveValue::NotSet, download_count: ActiveValue::NotSet,
} }
})) }))

View File

@ -13,6 +13,7 @@ pub struct Model {
pub authors: String, pub authors: String,
pub repository: String, pub repository: String,
pub description: String, pub description: String,
pub schema_version: i32,
pub download_count: i64, pub download_count: i64,
} }

View File

@ -16,7 +16,7 @@ async fn test_extensions(db: &Arc<Database>) {
let versions = db.get_known_extension_versions().await.unwrap(); let versions = db.get_known_extension_versions().await.unwrap();
assert!(versions.is_empty()); assert!(versions.is_empty());
let extensions = db.get_extensions(None, 5).await.unwrap(); let extensions = db.get_extensions(None, 1, 5).await.unwrap();
assert!(extensions.is_empty()); assert!(extensions.is_empty());
let t0 = OffsetDateTime::from_unix_timestamp_nanos(0).unwrap(); let t0 = OffsetDateTime::from_unix_timestamp_nanos(0).unwrap();
@ -33,6 +33,7 @@ async fn test_extensions(db: &Arc<Database>) {
description: "an extension".into(), description: "an extension".into(),
authors: vec!["max".into()], authors: vec!["max".into()],
repository: "ext1/repo".into(), repository: "ext1/repo".into(),
schema_version: 1,
published_at: t0, published_at: t0,
}, },
NewExtensionVersion { NewExtensionVersion {
@ -41,6 +42,7 @@ async fn test_extensions(db: &Arc<Database>) {
description: "a good extension".into(), description: "a good extension".into(),
authors: vec!["max".into(), "marshall".into()], authors: vec!["max".into(), "marshall".into()],
repository: "ext1/repo".into(), repository: "ext1/repo".into(),
schema_version: 1,
published_at: t0, published_at: t0,
}, },
], ],
@ -53,6 +55,7 @@ async fn test_extensions(db: &Arc<Database>) {
description: "a great extension".into(), description: "a great extension".into(),
authors: vec!["marshall".into()], authors: vec!["marshall".into()],
repository: "ext2/repo".into(), repository: "ext2/repo".into(),
schema_version: 0,
published_at: t0, published_at: t0,
}], }],
), ),
@ -75,7 +78,7 @@ async fn test_extensions(db: &Arc<Database>) {
); );
// The latest version of each extension is returned. // The latest version of each extension is returned.
let extensions = db.get_extensions(None, 5).await.unwrap(); let extensions = db.get_extensions(None, 1, 5).await.unwrap();
assert_eq!( assert_eq!(
extensions, extensions,
&[ &[
@ -102,6 +105,22 @@ async fn test_extensions(db: &Arc<Database>) {
] ]
); );
// Extensions with too new of a schema version are excluded.
let extensions = db.get_extensions(None, 0, 5).await.unwrap();
assert_eq!(
extensions,
&[ExtensionMetadata {
id: "ext2".into(),
name: "Extension Two".into(),
version: "0.2.0".into(),
authors: vec!["marshall".into()],
description: "a great extension".into(),
repository: "ext2/repo".into(),
published_at: t0,
download_count: 0
},]
);
// Record extensions being downloaded. // Record extensions being downloaded.
for _ in 0..7 { for _ in 0..7 {
assert!(db.record_extension_download("ext2", "0.0.2").await.unwrap()); assert!(db.record_extension_download("ext2", "0.0.2").await.unwrap());
@ -122,7 +141,7 @@ async fn test_extensions(db: &Arc<Database>) {
.unwrap()); .unwrap());
// Extensions are returned in descending order of total downloads. // Extensions are returned in descending order of total downloads.
let extensions = db.get_extensions(None, 5).await.unwrap(); let extensions = db.get_extensions(None, 1, 5).await.unwrap();
assert_eq!( assert_eq!(
extensions, extensions,
&[ &[
@ -161,6 +180,7 @@ async fn test_extensions(db: &Arc<Database>) {
description: "a real good extension".into(), description: "a real good extension".into(),
authors: vec!["max".into(), "marshall".into()], authors: vec!["max".into(), "marshall".into()],
repository: "ext1/repo".into(), repository: "ext1/repo".into(),
schema_version: 1,
published_at: t0, published_at: t0,
}], }],
), ),
@ -172,6 +192,7 @@ async fn test_extensions(db: &Arc<Database>) {
description: "an old extension".into(), description: "an old extension".into(),
authors: vec!["marshall".into()], authors: vec!["marshall".into()],
repository: "ext2/repo".into(), repository: "ext2/repo".into(),
schema_version: 0,
published_at: t0, published_at: t0,
}], }],
), ),
@ -196,7 +217,7 @@ async fn test_extensions(db: &Arc<Database>) {
.collect() .collect()
); );
let extensions = db.get_extensions(None, 5).await.unwrap(); let extensions = db.get_extensions(None, 1, 5).await.unwrap();
assert_eq!( assert_eq!(
extensions, extensions,
&[ &[

View File

@ -37,6 +37,7 @@ serde_json.workspace = true
settings.workspace = true settings.workspace = true
theme.workspace = true theme.workspace = true
toml.workspace = true toml.workspace = true
url.workspace = true
util.workspace = true util.workspace = true
wasm-encoder.workspace = true wasm-encoder.workspace = true
wasmtime.workspace = true wasmtime.workspace = true

View File

@ -29,6 +29,7 @@ pub struct ExtensionManifest {
pub id: Arc<str>, pub id: Arc<str>,
pub name: String, pub name: String,
pub version: Arc<str>, pub version: Arc<str>,
pub schema_version: i32,
#[serde(default)] #[serde(default)]
pub description: Option<String>, pub description: Option<String>,

View File

@ -35,6 +35,7 @@ use std::{
time::{Duration, Instant}, time::{Duration, Instant},
}; };
use theme::{ThemeRegistry, ThemeSettings}; use theme::{ThemeRegistry, ThemeSettings};
use url::Url;
use util::{ use util::{
http::{AsyncBody, HttpClient, HttpClientWithUrl}, http::{AsyncBody, HttpClient, HttpClientWithUrl},
paths::EXTENSIONS_DIR, paths::EXTENSIONS_DIR,
@ -49,6 +50,8 @@ pub use extension_manifest::{
const RELOAD_DEBOUNCE_DURATION: Duration = Duration::from_millis(200); const RELOAD_DEBOUNCE_DURATION: Duration = Duration::from_millis(200);
const FS_WATCH_LATENCY: Duration = Duration::from_millis(100); const FS_WATCH_LATENCY: Duration = Duration::from_millis(100);
const CURRENT_SCHEMA_VERSION: i64 = 1;
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct ExtensionsApiResponse { pub struct ExtensionsApiResponse {
pub data: Vec<ExtensionApiResponse>, pub data: Vec<ExtensionApiResponse>,
@ -377,15 +380,18 @@ impl ExtensionStore {
search: Option<&str>, search: Option<&str>,
cx: &mut ModelContext<Self>, cx: &mut ModelContext<Self>,
) -> Task<Result<Vec<ExtensionApiResponse>>> { ) -> Task<Result<Vec<ExtensionApiResponse>>> {
let url = self.http_client.build_zed_api_url(&format!( let version = CURRENT_SCHEMA_VERSION.to_string();
"/extensions{query}", let mut query = vec![("max_schema_version", version.as_str())];
query = search if let Some(search) = search {
.map(|search| format!("?filter={search}")) query.push(("filter", search));
.unwrap_or_default() }
));
let url = self.http_client.build_zed_api_url("/extensions", &query);
let http_client = self.http_client.clone(); let http_client = self.http_client.clone();
cx.spawn(move |_, _| async move { cx.spawn(move |_, _| async move {
let mut response = http_client.get(&url, AsyncBody::empty(), true).await?; let mut response = http_client
.get(&url?.as_ref(), AsyncBody::empty(), true)
.await?;
let mut body = Vec::new(); let mut body = Vec::new();
response response
@ -420,7 +426,7 @@ impl ExtensionStore {
fn install_or_upgrade_extension_at_endpoint( fn install_or_upgrade_extension_at_endpoint(
&mut self, &mut self,
extension_id: Arc<str>, extension_id: Arc<str>,
url: String, url: Url,
operation: ExtensionOperation, operation: ExtensionOperation,
cx: &mut ModelContext<Self>, cx: &mut ModelContext<Self>,
) { ) {
@ -447,7 +453,7 @@ impl ExtensionStore {
}); });
let mut response = http_client let mut response = http_client
.get(&url, Default::default(), true) .get(&url.as_ref(), Default::default(), true)
.await .await
.map_err(|err| anyhow!("error downloading extension: {}", err))?; .map_err(|err| anyhow!("error downloading extension: {}", err))?;
let decompressed_bytes = GzipDecoder::new(BufReader::new(response.body_mut())); let decompressed_bytes = GzipDecoder::new(BufReader::new(response.body_mut()));
@ -482,9 +488,13 @@ impl ExtensionStore {
) { ) {
log::info!("installing extension {extension_id} latest version"); log::info!("installing extension {extension_id} latest version");
let url = self let Some(url) = self
.http_client .http_client
.build_zed_api_url(&format!("/extensions/{extension_id}/download")); .build_zed_api_url(&format!("/extensions/{extension_id}/download"), &[])
.log_err()
else {
return;
};
self.install_or_upgrade_extension_at_endpoint( self.install_or_upgrade_extension_at_endpoint(
extension_id, extension_id,
@ -511,9 +521,16 @@ impl ExtensionStore {
cx: &mut ModelContext<Self>, cx: &mut ModelContext<Self>,
) { ) {
log::info!("installing extension {extension_id} {version}"); log::info!("installing extension {extension_id} {version}");
let url = self let Some(url) = self
.http_client .http_client
.build_zed_api_url(&format!("/extensions/{extension_id}/{version}/download")); .build_zed_api_url(
&format!("/extensions/{extension_id}/{version}/download"),
&[],
)
.log_err()
else {
return;
};
self.install_or_upgrade_extension_at_endpoint(extension_id, url, operation, cx); self.install_or_upgrade_extension_at_endpoint(extension_id, url, operation, cx);
} }
@ -1104,6 +1121,7 @@ fn manifest_from_old_manifest(
description: manifest_json.description, description: manifest_json.description,
repository: manifest_json.repository, repository: manifest_json.repository,
authors: manifest_json.authors, authors: manifest_json.authors,
schema_version: 0,
lib: Default::default(), lib: Default::default(),
themes: { themes: {
let mut themes = manifest_json.themes.into_values().collect::<Vec<_>>(); let mut themes = manifest_json.themes.into_values().collect::<Vec<_>>();

View File

@ -145,6 +145,7 @@ async fn test_extension_store(cx: &mut TestAppContext) {
id: "zed-ruby".into(), id: "zed-ruby".into(),
name: "Zed Ruby".into(), name: "Zed Ruby".into(),
version: "1.0.0".into(), version: "1.0.0".into(),
schema_version: 0,
description: None, description: None,
authors: Vec::new(), authors: Vec::new(),
repository: None, repository: None,
@ -169,6 +170,7 @@ async fn test_extension_store(cx: &mut TestAppContext) {
id: "zed-monokai".into(), id: "zed-monokai".into(),
name: "Zed Monokai".into(), name: "Zed Monokai".into(),
version: "2.0.0".into(), version: "2.0.0".into(),
schema_version: 0,
description: None, description: None,
authors: vec![], authors: vec![],
repository: None, repository: None,
@ -324,6 +326,7 @@ async fn test_extension_store(cx: &mut TestAppContext) {
id: "zed-gruvbox".into(), id: "zed-gruvbox".into(),
name: "Zed Gruvbox".into(), name: "Zed Gruvbox".into(),
version: "1.0.0".into(), version: "1.0.0".into(),
schema_version: 0,
description: None, description: None,
authors: vec![], authors: vec![],
repository: None, repository: None,

View File

@ -1,6 +1,10 @@
[package] [package]
name = "zed_extension_api" name = "zed_extension_api"
version = "0.1.0" version = "0.0.1"
description = "APIs for creating Zed extensions in Rust"
repository = "https://github.com/zed-industries/zed"
documentation = "https://docs.rs/zed_extension_api"
keywords = ["zed", "extension"]
edition = "2021" edition = "2021"
license = "Apache-2.0" license = "Apache-2.0"

View File

@ -0,0 +1,56 @@
# The Zed Rust Extension API
This crate lets you write extensions for Zed in Rust.
## Extension Manifest
You'll need an `extension.toml` file at the root of your extension directory, with the following structure:
```toml
id = "my-extension"
name = "My Extension"
description = "..."
version = "0.0.1"
schema_version = 1
authors = ["Your Name <you@example.com>"]
repository = "https://github.com/your/extension-repository"
```
## Cargo metadata
Zed extensions are packaged as WebAssembly files. In your Cargo.toml, you'll
need to set your `crate-type` accordingly:
```toml
[dependencies]
zed_extension_api = "0.0.1"
[lib]
crate-type = ["cdylib"]
```
## Implementing an Extension
To define your extension, create a type that implements the `Extension` trait, and register it.
```rust
use zed_extension_api as zed;
struct MyExtension {
// ... state
}
impl zed::Extension for MyExtension {
// ...
}
zed::register_extension!(MyExtension);
```
## Testing your extension
To run your extension in Zed as you're developing it:
- Open the extensions view using the `zed: extensions` action in the command palette.
- Click the `Add Dev Extension` button in the top right
- Choose the path to your extension directory.

View File

@ -94,6 +94,7 @@ async fn main() -> Result<()> {
version: manifest.version.to_string(), version: manifest.version.to_string(),
description: manifest.description, description: manifest.description,
authors: manifest.authors, authors: manifest.authors,
schema_version: Some(manifest.schema_version),
repository: manifest repository: manifest
.repository .repository
.ok_or_else(|| anyhow!("missing repository in extension manifest"))?, .ok_or_else(|| anyhow!("missing repository in extension manifest"))?,

View File

@ -684,7 +684,7 @@ impl Drop for RoomDelegate {
fn drop(&mut self) { fn drop(&mut self) {
unsafe { unsafe {
CFRelease(self.native_delegate.0); CFRelease(self.native_delegate.0);
let _ = Weak::from_raw(self.weak_room); let _ = Weak::from_raw(self.weak_room as *mut Room);
} }
} }
} }

View File

@ -7,4 +7,5 @@ pub struct ExtensionApiManifest {
pub description: Option<String>, pub description: Option<String>,
pub authors: Vec<String>, pub authors: Vec<String>,
pub repository: String, pub repository: String,
pub schema_version: Option<i32>,
} }

View File

@ -54,7 +54,7 @@ impl HttpClientWithUrl {
} }
/// Builds a Zed API URL using the given path. /// Builds a Zed API URL using the given path.
pub fn build_zed_api_url(&self, path: &str) -> String { pub fn build_zed_api_url(&self, path: &str, query: &[(&str, &str)]) -> Result<Url> {
let base_url = self.base_url(); let base_url = self.base_url();
let base_api_url = match base_url.as_ref() { let base_api_url = match base_url.as_ref() {
"https://zed.dev" => "https://api.zed.dev", "https://zed.dev" => "https://api.zed.dev",
@ -63,7 +63,10 @@ impl HttpClientWithUrl {
other => other, other => other,
}; };
format!("{}{}", base_api_url, path) Ok(Url::parse_with_params(
&format!("{}{}", base_api_url, path),
query,
)?)
} }
} }

View File

@ -783,7 +783,7 @@ async fn upload_previous_crashes(
.unwrap_or("zed-2024-01-17-221900.ips".to_string()); // don't upload old crash reports from before we had this. .unwrap_or("zed-2024-01-17-221900.ips".to_string()); // don't upload old crash reports from before we had this.
let mut uploaded = last_uploaded.clone(); let mut uploaded = last_uploaded.clone();
let crash_report_url = http.build_zed_api_url("/telemetry/crashes"); let crash_report_url = http.build_zed_api_url("/telemetry/crashes", &[])?;
for dir in [&*CRASHES_DIR, &*CRASHES_RETIRED_DIR] { for dir in [&*CRASHES_DIR, &*CRASHES_RETIRED_DIR] {
let mut children = smol::fs::read_dir(&dir).await?; let mut children = smol::fs::read_dir(&dir).await?;
@ -809,7 +809,7 @@ async fn upload_previous_crashes(
.await .await
.context("error reading crash file")?; .context("error reading crash file")?;
let mut request = Request::post(&crash_report_url) let mut request = Request::post(&crash_report_url.to_string())
.redirect_policy(isahc::config::RedirectPolicy::Follow) .redirect_policy(isahc::config::RedirectPolicy::Follow)
.header("Content-Type", "text/plain"); .header("Content-Type", "text/plain");

View File

@ -2,6 +2,7 @@ id = "gleam"
name = "Gleam" name = "Gleam"
description = "Gleam support for Zed" description = "Gleam support for Zed"
version = "0.0.1" version = "0.0.1"
schema_version = 1
authors = ["Marshall Bowers <elliott.codes@gmail.com>"] authors = ["Marshall Bowers <elliott.codes@gmail.com>"]
repository = "https://github.com/zed-industries/zed" repository = "https://github.com/zed-industries/zed"

View File

@ -2,6 +2,7 @@ id = "uiua"
name = "Uiua" name = "Uiua"
description = "Uiua support for Zed" description = "Uiua support for Zed"
version = "0.0.1" version = "0.0.1"
schema_version = 1
authors = ["Max Brunsfeld <max@zed.dev>"] authors = ["Max Brunsfeld <max@zed.dev>"]
repository = "https://github.com/zed-industries/zed" repository = "https://github.com/zed-industries/zed"