diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 48f47f3..0965303 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,7 +38,7 @@ jobs: - name: Test Servo engine host run: cargo test -p ely_servo_host --features servo-engine --test software_host - - name: Test PRD site compatibility + - name: Test embedded PRD site compatibility run: scripts/verify_prd_site_rendering.sh - name: Audit source file size diff --git a/Cargo.lock b/Cargo.lock index b2ff9b1..0ec016a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2243,24 +2243,17 @@ name = "ely_app" version = "0.1.0" dependencies = [ "ahash", - "core-foundation 0.10.0", - "core-video", "directories", "ed25519-dalek", "ely_browser_core", "ely_design_system", "ely_domain", + "ely_servo_host", "ely_sync_client", "gpui", "gpui-component", "gpui-component-assets", "image", - "io-surface", - "mach2", - "objc2", - "objc2-core-foundation", - "objc2-foundation", - "objc2-io-surface", "serde", "serde_json", "sha2", @@ -2270,7 +2263,6 @@ dependencies = [ "tracing-subscriber", "ureq", "url", - "uuid", ] [[package]] @@ -2309,16 +2301,12 @@ dependencies = [ "dpi", "ely_domain", "euclid", - "gleam", - "glow", - "image", - "log", - "mach2", - "objc2-io-surface", + "naga 26.0.0", + "raw-window-handle", + "rustls", "serde", "serde_json", "servo", - "surfman", "thiserror 2.0.18", "url", ] @@ -2498,17 +2486,6 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" -[[package]] -name = "erased-serde" -version = "0.4.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" -dependencies = [ - "serde", - "serde_core", - "typeid", -] - [[package]] name = "errno" version = "0.3.14" @@ -3521,8 +3498,6 @@ dependencies = [ [[package]] name = "gpui_http_client" version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23822b0a6d2c5e6a42507980a0ab3848610ea908942c8ef98187f646f690335e" dependencies = [ "anyhow", "async-compression", @@ -3593,8 +3568,6 @@ dependencies = [ [[package]] name = "gpui_sum_tree" version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4f3bedd573fafafa13d1200b356c588cf094fb2786e3684bb3f5ea59b549fa9" dependencies = [ "arrayvec", "log", @@ -3604,8 +3577,6 @@ dependencies = [ [[package]] name = "gpui_util" version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68faea25903ae524de9af83990b9aa51bcbc8dd085929ac0aea7fd41905e05c3" dependencies = [ "anyhow", "async-fs", @@ -5288,7 +5259,6 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" dependencies = [ - "serde_core", "value-bag", ] @@ -6142,8 +6112,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.11.1", - "block2", - "libc", "objc2", "objc2-core-foundation", ] @@ -6158,7 +6126,6 @@ dependencies = [ "libc", "objc2", "objc2-core-foundation", - "objc2-foundation", ] [[package]] @@ -8052,15 +8019,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "serde_fmt" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e497af288b3b95d067a23a4f749f2861121ffcb2f6d8379310dcda040c345ed" -dependencies = [ - "serde_core", -] - [[package]] name = "serde_json" version = "1.0.149" @@ -10186,84 +10144,6 @@ dependencies = [ "x11-dl", ] -[[package]] -name = "sval" -version = "2.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2eb9318255ebd817902d7e279d8f8e39b35b1b9954decd5eb9ea0e30e5fd2b6a" - -[[package]] -name = "sval_buffer" -version = "2.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12571299185e653fdb0fbfe36cd7f6529d39d4e747a60b15a3f34574b7b97c61" -dependencies = [ - "sval", - "sval_ref", -] - -[[package]] -name = "sval_dynamic" -version = "2.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39526f24e997706c0de7f03fb7371f7f5638b66a504ded508e20ad173d0a3677" -dependencies = [ - "sval", -] - -[[package]] -name = "sval_fmt" -version = "2.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "933dd3bb26965d682280fcc49400ac2a05036f4ee1e6dbd61bf8402d5a5c3a54" -dependencies = [ - "itoa", - "ryu", - "sval", -] - -[[package]] -name = "sval_json" -version = "2.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0cda08f6d5c9948024a6551077557b1fdcc3880ff2f20ae839667d2ec2d87ed" -dependencies = [ - "itoa", - "ryu", - "sval", -] - -[[package]] -name = "sval_nested" -version = "2.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88d49d5e6c1f9fd0e53515819b03a97ca4eb1bff5c8ee097c43391c09ecfb19f" -dependencies = [ - "sval", - "sval_buffer", - "sval_ref", -] - -[[package]] -name = "sval_ref" -version = "2.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14f876c5a78405375b4e19cbb9554407513b59c93dea12dc6a4af4e1d30899ca" -dependencies = [ - "sval", -] - -[[package]] -name = "sval_serde" -version = "2.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f9ccd3b7f7200239a655e517dd3fd48d960b9111ad24bd6a5e055bef17607c7" -dependencies = [ - "serde_core", - "sval", - "sval_nested", -] - [[package]] name = "svg_fmt" version = "0.4.5" @@ -11061,12 +10941,6 @@ dependencies = [ "thiserror 2.0.18", ] -[[package]] -name = "typeid" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" - [[package]] name = "typenum" version = "1.20.0" @@ -11381,36 +11255,6 @@ name = "value-bag" version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ba6f5989077681266825251a52748b8c1d8a4ad098cc37e440103d0ea717fc0" -dependencies = [ - "value-bag-serde1", - "value-bag-sval2", -] - -[[package]] -name = "value-bag-serde1" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16530907bfe2999a1773ca5900a65101e092c70f642f25cc23ca0c43573262c5" -dependencies = [ - "erased-serde", - "serde_core", - "serde_fmt", -] - -[[package]] -name = "value-bag-sval2" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d00ae130edd690eaa877e4f40605d534790d1cf1d651e7685bd6a144521b251f" -dependencies = [ - "sval", - "sval_buffer", - "sval_dynamic", - "sval_fmt", - "sval_json", - "sval_ref", - "sval_serde", -] [[package]] name = "vcpkg" @@ -13304,8 +13148,6 @@ dependencies = [ [[package]] name = "zed-sum-tree" version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d490156d0d7311855564d6e1d6dccab992405a0c0e15e1c8ef18920c02177e35" dependencies = [ "arrayvec", "log", diff --git a/Cargo.toml b/Cargo.toml index 0a50608..4a3f3b9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,8 +37,12 @@ url = "2.5.4" uuid = { version = "1.12.1", features = ["v7"] } [patch.crates-io] -# Local GPUI 0.2.2 patch: macOS `surface(CVPixelBuffer)` accepts Servo's BGRA IOSurfaces. +# Local GPUI 0.2.2 patch used by the native shell while the upstream crate is pinned. gpui = { path = "third_party/gpui" } +gpui_http_client = { path = "third_party/gpui_support/gpui_http_client" } +gpui_sum_tree = { path = "third_party/gpui_support/gpui_sum_tree" } +gpui_util = { path = "third_party/gpui_support/gpui_util" } +zed-sum-tree = { path = "third_party/gpui_support/zed-sum-tree" } [workspace.lints.rust] unsafe_code = "deny" diff --git a/README.md b/README.md index 73b147d..70dc57d 100644 --- a/README.md +++ b/README.md @@ -14,8 +14,8 @@ cargo fmt --all --check cargo check --workspace --all-targets cargo clippy --workspace --all-targets -- -D warnings cargo test --workspace --all-targets -cargo check -p ely_servo_host --features servo-engine,hardware-render --all-targets -cargo clippy -p ely_servo_host --features servo-engine,hardware-render --all-targets -- -D warnings +cargo check -p ely_servo_host --features servo-engine --all-targets +cargo clippy -p ely_servo_host --features servo-engine --all-targets -- -D warnings cargo test -p ely_servo_host --features servo-engine --test software_host scripts/verify_prd_site_rendering.sh scripts/verify_windows_app_manifest.sh diff --git a/crates/ely_app/Cargo.toml b/crates/ely_app/Cargo.toml index 7e764a3..bd44f09 100644 --- a/crates/ely_app/Cargo.toml +++ b/crates/ely_app/Cargo.toml @@ -15,6 +15,7 @@ ed25519-dalek.workspace = true ely_browser_core = { path = "../ely_browser_core" } ely_design_system = { path = "../ely_design_system" } ely_domain = { path = "../ely_domain" } +ely_servo_host = { path = "../ely_servo_host", features = ["servo-engine"] } ely_sync_client = { path = "../ely_sync_client" } gpui.workspace = true gpui-component.workspace = true @@ -29,20 +30,6 @@ tracing-subscriber.workspace = true ureq.workspace = true url.workspace = true -[target.'cfg(target_os = "macos")'.dependencies] -core-foundation = "0.10" -# Pin to the same minor as gpui 0.2.2 so we share its `CVPixelBuffer` -# type — gpui's `Surface::From` only matches the -# version it built against. -core-video = "0.4" -io-surface = "0.16" -mach2 = "0.6" -objc2 = "0.6" -objc2-core-foundation = { version = "0.3.2", features = ["CFBase", "CFDictionary", "CFNumber", "CFString"] } -objc2-foundation = { version = "0.3.1", features = ["NSDictionary", "NSString", "NSValue"] } -objc2-io-surface = "0.3.2" -uuid.workspace = true - [dev-dependencies] gpui = { workspace = true, features = ["test-support"] } diff --git a/crates/ely_app/src/main.rs b/crates/ely_app/src/main.rs index d7eb716..44275ca 100644 --- a/crates/ely_app/src/main.rs +++ b/crates/ely_app/src/main.rs @@ -324,15 +324,17 @@ fn quit(_: &Quit, cx: &mut App) { cx.quit(); } +const DEFAULT_TRACING_FILTER: &str = "ely_app=info,ely_servo_host=warn,ely=info"; + /// Install the global tracing subscriber. `RUST_LOG` drives the -/// filter; absent it, only `warn` and above leak through so day-to-day -/// runs stay quiet. The perf target is silent by default — -/// `RUST_LOG=ely::servo::perf=info` flips on the frame-time stream -/// without touching the rest of the app. We swallow re-init errors so -/// tests that share the process state with main don't blow up. +/// filter; absent it, app-owned targets stay visible and Servo internals +/// stay opt-in. `RUST_LOG=ely::servo::perf=info` flips on the frame-time +/// stream. We swallow re-init errors so tests that share process state +/// with main can reuse this path. fn init_tracing() { use tracing_subscriber::{EnvFilter, fmt}; - let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("warn")); + let filter = EnvFilter::try_from_default_env() + .unwrap_or_else(|_| EnvFilter::new(DEFAULT_TRACING_FILTER)); let _ = fmt().with_env_filter(filter).with_target(true).try_init(); } diff --git a/crates/ely_app/src/services/iosurface_mach.rs b/crates/ely_app/src/services/iosurface_mach.rs deleted file mode 100644 index 2ea50b6..0000000 --- a/crates/ely_app/src/services/iosurface_mach.rs +++ /dev/null @@ -1,233 +0,0 @@ -use std::{ - collections::BTreeMap, - ffi::CString, - mem, - time::{Duration, Instant}, -}; - -use mach2::{ - bootstrap::{bootstrap_port, bootstrap_register}, - kern_return::KERN_SUCCESS, - mach_port::{mach_port_allocate, mach_port_destroy, mach_port_insert_right}, - message::{ - MACH_MSG_PORT_DESCRIPTOR, MACH_MSG_SUCCESS, MACH_MSG_TIMEOUT_NONE, MACH_MSG_TYPE_MAKE_SEND, - MACH_RCV_MSG, MACH_RCV_TIMED_OUT, MACH_RCV_TIMEOUT, mach_msg, mach_msg_body_t, - mach_msg_header_t, mach_msg_port_descriptor_t, mach_msg_trailer_t, - }, - port::{MACH_PORT_NULL, MACH_PORT_RIGHT_RECEIVE, mach_port_t}, - traps::mach_task_self, -}; -use thiserror::Error; -use uuid::Uuid; - -const IOSURFACE_PORT_MESSAGE_ID: i32 = 0x454c_5901; -const SERVICE_PREFIX: &str = "com.ely.browser.iosurface"; - -pub(crate) struct IOSurfaceMachReceiver { - service_name: String, - receive_port: mach_port_t, - pending_ports: BTreeMap, -} - -#[derive(Debug, Error)] -pub(crate) enum IOSurfaceMachError { - #[error("Mach service name contains an interior nul byte")] - InvalidServiceName, - - #[error("mach_port_allocate returned {code}")] - AllocatePort { code: i32 }, - - #[error("mach_port_insert_right returned {code}")] - InsertSendRight { code: i32 }, - - #[error("bootstrap_register returned {code}")] - RegisterService { code: i32 }, - - #[error("mach_msg receive timed out for IOSurface surface {surface_id:#x}")] - ReceiveTimedOut { surface_id: u64 }, - - #[error("mach_msg receive returned {code}")] - Receive { code: i32 }, - - #[error("received unexpected Mach message id {message_id}")] - UnexpectedMessage { message_id: i32 }, - - #[error("received invalid IOSurface Mach message")] - InvalidMessage, -} - -impl IOSurfaceMachReceiver { - pub(crate) fn new() -> Result { - let service_name = unique_service_name(); - let service_name_c = CString::new(service_name.as_str()) - .map_err(|_| IOSurfaceMachError::InvalidServiceName)?; - let mut receive_port = MACH_PORT_NULL; - - #[expect(unsafe_code)] - let task = unsafe { mach_task_self() }; - #[expect(unsafe_code)] - let allocate = - unsafe { mach_port_allocate(task, MACH_PORT_RIGHT_RECEIVE, &mut receive_port) }; - if allocate != KERN_SUCCESS { - return Err(IOSurfaceMachError::AllocatePort { code: allocate }); - } - - #[expect(unsafe_code)] - let insert = unsafe { - mach_port_insert_right(task, receive_port, receive_port, MACH_MSG_TYPE_MAKE_SEND) - }; - if insert != KERN_SUCCESS { - destroy_port(receive_port); - return Err(IOSurfaceMachError::InsertSendRight { code: insert }); - } - - #[expect(unsafe_code)] - #[allow(deprecated)] - let register = unsafe { - bootstrap_register(bootstrap_port, service_name_c.as_ptr() as *mut _, receive_port) - }; - if register != KERN_SUCCESS { - destroy_port(receive_port); - return Err(IOSurfaceMachError::RegisterService { code: register }); - } - - Ok(Self { service_name, receive_port, pending_ports: BTreeMap::new() }) - } - - pub(crate) fn service_name(&self) -> &str { - self.service_name.as_str() - } - - pub(crate) fn receive_port_for_surface( - &mut self, - surface_id: u64, - timeout: Duration, - ) -> Result { - if let Some(port) = self.pending_ports.remove(&surface_id) { - return Ok(port); - } - - let deadline = Instant::now() + timeout; - loop { - let remaining = deadline.saturating_duration_since(Instant::now()); - if remaining.is_zero() { - return Err(IOSurfaceMachError::ReceiveTimedOut { surface_id }); - } - let Some(received) = self.receive_one(remaining)? else { - return Err(IOSurfaceMachError::ReceiveTimedOut { surface_id }); - }; - if received.surface_id == surface_id { - return Ok(received.mach_port); - } - self.pending_ports.insert(received.surface_id, received.mach_port); - } - } - - fn receive_one( - &self, - timeout: Duration, - ) -> Result, IOSurfaceMachError> { - #[expect(unsafe_code)] - let mut received_message: ReceivedIOSurfacePortMessage = unsafe { mem::zeroed() }; - let timeout_ms = timeout_millis(timeout); - #[expect(unsafe_code)] - let result = unsafe { - mach_msg( - &mut received_message.message.header, - MACH_RCV_MSG | MACH_RCV_TIMEOUT, - 0, - mem::size_of::() as u32, - self.receive_port, - timeout_ms, - MACH_PORT_NULL, - ) - }; - - if result == MACH_RCV_TIMED_OUT { - return Ok(None); - } - if result != MACH_MSG_SUCCESS { - return Err(IOSurfaceMachError::Receive { code: result }); - } - let message = &mut received_message.message; - if message.header.msgh_id != IOSURFACE_PORT_MESSAGE_ID { - destroy_message(message); - return Err(IOSurfaceMachError::UnexpectedMessage { - message_id: message.header.msgh_id, - }); - } - if message.body.msgh_descriptor_count != 1 - || message.surface_port.type_ != MACH_MSG_PORT_DESCRIPTOR as u8 - || message.surface_port.name == MACH_PORT_NULL - { - destroy_message(message); - return Err(IOSurfaceMachError::InvalidMessage); - } - - Ok(Some(ReceivedSurfacePort { - surface_id: message.surface_id, - mach_port: message.surface_port.name, - })) - } -} - -impl Drop for IOSurfaceMachReceiver { - fn drop(&mut self) { - for port in std::mem::take(&mut self.pending_ports).into_values() { - deallocate_port(port); - } - destroy_port(self.receive_port); - } -} - -struct ReceivedSurfacePort { - surface_id: u64, - mach_port: mach_port_t, -} - -#[repr(C)] -struct IOSurfacePortMessage { - header: mach_msg_header_t, - body: mach_msg_body_t, - surface_port: mach_msg_port_descriptor_t, - surface_id: u64, -} - -#[repr(C)] -struct ReceivedIOSurfacePortMessage { - message: IOSurfacePortMessage, - _trailer: mach_msg_trailer_t, -} - -fn unique_service_name() -> String { - format!("{SERVICE_PREFIX}.{}", Uuid::now_v7().as_simple()) -} - -fn timeout_millis(timeout: Duration) -> u32 { - u32::try_from(timeout.as_millis()).unwrap_or(u32::MAX).max(MACH_MSG_TIMEOUT_NONE + 1) -} - -fn destroy_message(message: &mut IOSurfacePortMessage) { - #[expect(unsafe_code)] - unsafe { - mach2::message::mach_msg_destroy(&mut message.header); - } -} - -fn destroy_port(port: mach_port_t) { - #[expect(unsafe_code)] - let task = unsafe { mach_task_self() }; - #[expect(unsafe_code)] - unsafe { - let _ = mach_port_destroy(task, port); - } -} - -fn deallocate_port(port: mach_port_t) { - #[expect(unsafe_code)] - let task = unsafe { mach_task_self() }; - #[expect(unsafe_code)] - unsafe { - let _ = mach2::mach_port::mach_port_deallocate(task, port); - } -} diff --git a/crates/ely_app/src/services/iosurface_metal.rs b/crates/ely_app/src/services/iosurface_metal.rs deleted file mode 100644 index 38001fc..0000000 --- a/crates/ely_app/src/services/iosurface_metal.rs +++ /dev/null @@ -1,306 +0,0 @@ -//! macOS-only import of cross-process IOSurface handles into -//! `CVPixelBuffer`s that preserve the sidecar's IOSurface identity. -//! -//! `T10.4` originally imported the IOSurface into an `MTLTexture` -//! directly. GPUI 0.2.2 exposes `Window::paint_surface` / -//! `elements::surface::Surface` for `CVPixelBuffer`; the local GPUI -//! patch adds a BGRA fragment pipeline for Servo's hardware -//! IOSurfaces, so this cache is the renderer-side handoff point. -//! -//! Lifetime contract: -//! -//! * `IOSurfaceCreateMachPort` (sidecar side) gives the receiver a -//! send right whose refcount is 1 in our task. After we resolve -//! the surface and wrap it in a CVPixelBuffer, the mach port has -//! done its job. -//! * `CVPixelBufferCreateWithIOSurface` retains the IOSurface for -//! the pixel buffer's lifetime. We `mach_port_deallocate` -//! immediately so the receiver process doesn't accumulate idle -//! mach send rights. -//! * Dropping `IOSurfaceCache` releases every cached -//! `CVPixelBuffer`, which in turn releases each retained -//! IOSurface. The sidecar still holds its own retain via surfman, -//! so the IOSurface itself outlives our cache for as long as the -//! sidecar keeps painting. - -#![cfg(target_os = "macos")] - -use std::collections::HashMap; - -use core_foundation::base::TCFType as _; -use core_video::pixel_buffer::CVPixelBuffer; -#[allow(deprecated)] -use io_surface::IOSurface; -use thiserror::Error; - -/// Cache of imported `CVPixelBuffer`s keyed by IOSurface identity. -/// Constructed lazily by the renderer-side client on the first -/// hardware-path frame. -pub(crate) struct IOSurfaceCache { - pixel_buffers: HashMap, -} - -// SAFETY: CVPixelBuffer wraps CVPixelBufferRef, a CoreFoundation type -// Apple documents as safe to share across threads. The cache is owned -// by ServoLiveClient which now lives on the LiveRuntimeWorker thread, -// so the auto-Send check (rightly) rejects the raw pointer inside the -// crate's `CVPixelBuffer`. The pointer is atomically refcounted CFTypeRef -// and only mutated via Mach IPC, which is itself thread-safe. -#[expect(unsafe_code)] -unsafe impl Send for IOSurfaceCache {} - -struct CachedPixelBuffer { - pixel_buffer: CVPixelBuffer, - width: u32, - height: u32, -} - -#[derive(Debug, Error)] -pub(crate) enum SurfaceImportError { - #[error("IOSurfaceLookupFromMachPort returned null for port 0x{port:x}")] - LookupFailed { port: u32 }, - #[error("CVPixelBufferCreateWithIOSurface returned status {status}")] - PixelBufferBuildFailed { status: i32 }, -} - -impl IOSurfaceCache { - pub fn new() -> Self { - Self { pixel_buffers: HashMap::new() } - } - - /// Import an IOSurface published by the sidecar's - /// `surface_handle` field. Idempotent on `surface_id` plus pixel - /// dimensions: duplicate handles for the same sized IOSurface are - /// discarded, while a resized IOSurface that reuses the same - /// `surface_id` replaces the cached pixel buffer. - #[cfg(test)] - pub fn import( - &mut self, - mach_port_name: u32, - surface_id: u64, - ) -> Result<(), SurfaceImportError> { - let pixel_buffer = import_pixel_buffer_from_mach_port(mach_port_name)?; - self.insert_pixel_buffer(surface_id, pixel_buffer); - Ok(()) - } - - pub(crate) fn insert_pixel_buffer(&mut self, surface_id: u64, pixel_buffer: CVPixelBuffer) { - let width = pixel_buffer.get_width() as u32; - let height = pixel_buffer.get_height() as u32; - - if self - .pixel_buffers - .get(&surface_id) - .is_some_and(|cached| cached.width == width && cached.height == height) - { - return; - } - - self.pixel_buffers.insert(surface_id, CachedPixelBuffer { pixel_buffer, width, height }); - } - - /// Look up an already-imported pixel buffer by `surface_id`. The - /// receiver's per-frame `current_surface_id` selects which of the - /// swap chain's rotating front/back surfaces to sample. Returns a - /// clone (CVPixelBuffer is reference-counted; cloning is a cheap - /// atomic increment) so the caller can hand it to GPUI's - /// `surface(...)` element without holding a borrow on the cache. - pub fn pixel_buffer_for(&self, surface_id: u64) -> Option { - self.pixel_buffers.get(&surface_id).map(|cached| cached.pixel_buffer.clone()) - } - - pub(crate) fn surface_ids(&self) -> Vec { - self.pixel_buffers.keys().copied().collect() - } - - #[cfg(test)] - pub fn cached_surface_count(&self) -> usize { - self.pixel_buffers.len() - } -} - -pub(crate) fn import_pixel_buffer_from_mach_port( - mach_port_name: u32, -) -> Result { - let result = build_pixel_buffer_from_mach_port(mach_port_name); - deallocate_mach_port(mach_port_name); - result -} - -fn build_pixel_buffer_from_mach_port( - mach_port_name: u32, -) -> Result { - let Some(iosurface) = objc2_io_surface::IOSurfaceRef::lookup_from_mach_port(mach_port_name) - else { - return Err(SurfaceImportError::LookupFailed { port: mach_port_name }); - }; - - // Both objc2-io-surface and the legacy `io_surface` crate wrap - // the same C `__IOSurface` pointer. CVPixelBufferCreateWithIOSurface - // (via core-video) expects the legacy crate's wrapper. Reach for - // the raw pointer and let TCFType CFRetain it independently so - // both Rust handles can drop without double-freeing. - let raw_ptr: *const std::ffi::c_void = - (&*iosurface) as *const objc2_io_surface::IOSurfaceRef as *const std::ffi::c_void; - #[allow(deprecated)] - let io_surface_view: IOSurface = { - #[expect(unsafe_code)] - unsafe { - IOSurface::wrap_under_get_rule(raw_ptr as io_surface::IOSurfaceRef) - } - }; - - CVPixelBuffer::from_io_surface(&io_surface_view, None) - .map_err(|status| SurfaceImportError::PixelBufferBuildFailed { status }) -} - -/// Release one send right against the mach port we received. The -/// IOSurface itself stays alive because the `CVPixelBuffer` (or the -/// sidecar's surfman) still retain it. -fn deallocate_mach_port(port: u32) { - #[expect(unsafe_code)] - let result = unsafe { mach_port_deallocate(mach_task_self_, port) }; - if result != KERN_SUCCESS { - tracing::warn!( - target: "ely::servo::iosurface", - mach_port_name = port, - kern_result = result, - "mach_port_deallocate returned non-success", - ); - } -} - -const KERN_SUCCESS: i32 = 0; - -#[expect(unsafe_code)] -unsafe extern "C" { - /// Global mach task port for the running process. Defined in - /// `mach/mach_init.h` as `extern mach_port_t mach_task_self_;`. - static mach_task_self_: u32; - - /// Releases one send right against `name` within `task`. We only - /// ever call this with our own task; the IOSurface keeps its - /// retain via the CVPixelBuffer so this just frees our port slot. - fn mach_port_deallocate(task: u32, name: u32) -> i32; -} - -#[cfg(test)] -mod tests { - use super::IOSurfaceCache; - use objc2_core_foundation::{ - CFDictionary, CFIndex, CFNumber, CFRetained, CFString, kCFAllocatorDefault, - kCFTypeDictionaryKeyCallBacks, kCFTypeDictionaryValueCallBacks, - }; - use objc2_io_surface::{ - IOSurfaceRef, kIOSurfaceBytesPerElement, kIOSurfaceBytesPerRow, kIOSurfaceHeight, - kIOSurfacePixelFormat, kIOSurfaceWidth, - }; - use std::os::raw::c_void; - - /// Build a CPU-backed IOSurface from scratch, the same way - /// surfman's macOS backend does. - /// - /// BGRA8 (four-cc '32BGRA'), width + height + bytes_per_element - /// + bytes_per_row live in a Core Foundation properties dictionary. - /// - /// The pointer-casts mirror - /// `surfman::platform::macos::system::surface::create_io_surface`. - fn build_local_iosurface(width: u32, height: u32) -> Result, String> { - let pixel_format: i32 = i32::from_be_bytes(*b"BGRA"); - let bytes_per_element: i32 = 4; - let bytes_per_row: i32 = (width as i32) * bytes_per_element; - - let width_num = CFNumber::new_i32(width as i32); - let height_num = CFNumber::new_i32(height as i32); - let bpe_num = CFNumber::new_i32(bytes_per_element); - let bpr_num = CFNumber::new_i32(bytes_per_row); - let pf_num = CFNumber::new_i32(pixel_format); - - #[expect(unsafe_code)] - unsafe { - let keys: [&CFString; 5] = [ - kIOSurfaceWidth, - kIOSurfaceHeight, - kIOSurfaceBytesPerElement, - kIOSurfaceBytesPerRow, - kIOSurfacePixelFormat, - ]; - let values: [&CFNumber; 5] = [&width_num, &height_num, &bpe_num, &bpr_num, &pf_num]; - let keys_ptr: *mut *const c_void = keys.as_ptr() as *mut *const c_void; - let values_ptr: *mut *const c_void = values.as_ptr() as *mut *const c_void; - let properties = CFDictionary::new( - kCFAllocatorDefault, - keys_ptr, - values_ptr, - keys.len() as CFIndex, - &kCFTypeDictionaryKeyCallBacks, - &kCFTypeDictionaryValueCallBacks, - ) - .ok_or_else(|| "CFDictionaryCreate returned null".to_string())?; - IOSurfaceRef::new(&properties) - .ok_or_else(|| "IOSurfaceCreate returned null".to_string()) - } - } - - #[test] - fn imports_local_iosurface_into_pixel_buffer() -> Result<(), String> { - let mut cache = IOSurfaceCache::new(); - let iosurface = build_local_iosurface(64, 48)?; - let mach_port = iosurface.create_mach_port(); - assert!(mach_port != 0, "IOSurfaceCreateMachPort must yield a real port"); - let surface_id: u64 = 0xDEAD_BEEFu64; - - cache.import(mach_port, surface_id).map_err(|error| error.to_string())?; - - let pixel_buffer = cache - .pixel_buffer_for(surface_id) - .ok_or_else(|| "imported pixel buffer was missing".to_string())?; - assert_eq!( - pixel_buffer.get_width() as u32, - 64, - "CVPixelBuffer width must match the source IOSurface", - ); - assert_eq!( - pixel_buffer.get_height() as u32, - 48, - "CVPixelBuffer height must match the source IOSurface", - ); - assert_eq!(cache.cached_surface_count(), 1); - Ok(()) - } - - #[test] - fn second_import_with_same_surface_id_is_idempotent() -> Result<(), String> { - let mut cache = IOSurfaceCache::new(); - let iosurface = build_local_iosurface(64, 48)?; - let port_a = iosurface.create_mach_port(); - let port_b = iosurface.create_mach_port(); - assert!(port_a != 0 && port_b != 0 && port_a != port_b); - - cache.import(port_a, 0xAAAA_AAAA).map_err(|error| error.to_string())?; - // Same surface_id → defensive dedup path; port_b is deallocated - // without minting a duplicate CVPixelBuffer. - cache.import(port_b, 0xAAAA_AAAA).map_err(|error| error.to_string())?; - assert_eq!(cache.cached_surface_count(), 1); - Ok(()) - } - - #[test] - fn same_surface_id_with_changed_dimensions_replaces_pixel_buffer() -> Result<(), String> { - let mut cache = IOSurfaceCache::new(); - let initial = build_local_iosurface(64, 48)?; - let resized = build_local_iosurface(96, 72)?; - let surface_id = 0xBBBB_BBBB; - - cache.import(initial.create_mach_port(), surface_id).map_err(|error| error.to_string())?; - cache.import(resized.create_mach_port(), surface_id).map_err(|error| error.to_string())?; - - let pixel_buffer = cache - .pixel_buffer_for(surface_id) - .ok_or_else(|| "resized pixel buffer was missing".to_string())?; - assert_eq!(pixel_buffer.get_width() as u32, 96); - assert_eq!(pixel_buffer.get_height() as u32, 72); - assert_eq!(cache.cached_surface_count(), 1); - Ok(()) - } -} diff --git a/crates/ely_app/src/services/mod.rs b/crates/ely_app/src/services/mod.rs index ee2483f..288f835 100644 --- a/crates/ely_app/src/services/mod.rs +++ b/crates/ely_app/src/services/mod.rs @@ -1,16 +1,11 @@ pub mod download_checksums; pub mod download_files; pub mod http_downloads; -#[cfg(target_os = "macos")] -pub(crate) mod iosurface_mach; -#[cfg(target_os = "macos")] -pub(crate) mod iosurface_metal; pub mod plugin_package_store; pub mod plugin_packages; pub mod plugin_signatures; pub mod servo_live; pub(crate) mod servo_profile_data; -mod servo_sidecar_command; pub(crate) use servo_profile_data::ProfileDataMode; diff --git a/crates/ely_app/src/services/servo_live.rs b/crates/ely_app/src/services/servo_live.rs index 0efcd25..7f07653 100644 --- a/crates/ely_app/src/services/servo_live.rs +++ b/crates/ely_app/src/services/servo_live.rs @@ -1,286 +1,300 @@ -use std::{ - io::{BufRead, BufReader, Read, Write}, - path::PathBuf, - process::{Child, ChildStdin, ChildStdout, Stdio}, +use std::{collections::BTreeMap, path::PathBuf}; + +use ely_domain::{ + ProfileId, SiteOrigin, SitePermissionDecision, SitePermissionFeature, TabId, UrlText, WebViewId, +}; +use ely_servo_host::{ + HidpiScaleRequest, KeyboardTextRequest, MouseClickRequest, MouseHoverRequest, + NavigationRequest, PageZoomRequest, PermissionDecision, PermissionRequest, ResizeRequest, + ScrollRequest, ServoHost, ServoSurfaceSize, SoftwareServoHost, }; -#[cfg(target_os = "macos")] -#[path = "servo_live_iosurface_importer.rs"] -mod iosurface_importer; #[path = "servo_live_types.rs"] mod types; -/// Environment variable that lets the user pick the rendering context -/// kind used by the spawned sidecar. Accepted values: `software` -/// and `hardware`. macOS defaults to the hardware path and receives -/// IOSurface mach send rights over a side Mach channel. -#[path = "servo_live_wire.rs"] -mod wire; pub(crate) use types::{ ServoLiveEnsureRequest, ServoLiveError, ServoLiveFrame, ServoLiveSitePermission, }; -use super::servo_sidecar_command::{ - SidecarRenderingContext, default_sidecar_command, rendering_context_from_env, -}; -use wire::{ - LiveRequest, LiveResponse, LiveSurfaceHandle, log_frame_perf, log_iosurface_current, - log_iosurface_handle, -}; - -#[cfg(target_os = "macos")] -use super::iosurface_metal::IOSurfaceCache; -#[cfg(target_os = "macos")] -use iosurface_importer::{IOSurfaceImportResult, IOSurfaceImportWorker}; - pub(crate) struct ServoLiveClient { - child: Child, - stdin: ChildStdin, - stdout: BufReader, - /// Cache of imported `CVPixelBuffer`s keyed by surface_id. Built - /// lazily on the first `surface_handle` the sidecar publishes — - /// software-path tabs never trigger construction. - #[cfg(target_os = "macos")] - iosurface_cache: IOSurfaceCache, - #[cfg(target_os = "macos")] - iosurface_importer: Option, + host: SoftwareServoHost, + sessions: BTreeMap, } impl ServoLiveClient { pub fn new(profile_data_dir: PathBuf) -> Result { - let command_target = default_sidecar_command()?; - if let Some(path) = command_target.missing_binary_path() { - return Err(ServoLiveError::SidecarBinaryUnavailable { path: path.to_path_buf() }); - } - - let rendering_context = rendering_context_from_env(); - let mut command = command_target.command(); - command.arg("live").arg("--profile-data-dir").arg(profile_data_dir); - command.arg("--rendering-context").arg(rendering_context.cli_arg()); - #[cfg(target_os = "macos")] - let iosurface_importer = if rendering_context == SidecarRenderingContext::Hardware { - let receiver = super::iosurface_mach::IOSurfaceMachReceiver::new()?; - command.arg("--iosurface-mach-service").arg(receiver.service_name()); - Some( - IOSurfaceImportWorker::new(receiver) - .map_err(ServoLiveError::IOSurfaceImportWorker)?, - ) - } else { - None - }; - let mut child = command - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .spawn() - .map_err(ServoLiveError::Command)?; - let stdin = child.stdin.take().ok_or(ServoLiveError::PipeUnavailable { name: "stdin" })?; - let stdout = - child.stdout.take().ok_or(ServoLiveError::PipeUnavailable { name: "stdout" })?; - - Ok(Self { - child, - stdin, - stdout: BufReader::new(stdout), - #[cfg(target_os = "macos")] - iosurface_cache: IOSurfaceCache::new(), - #[cfg(target_os = "macos")] - iosurface_importer, - }) + let host = SoftwareServoHost::new_with_config_dir( + ServoSurfaceSize::new(1, 1), + Some(profile_data_dir), + )?; + Ok(Self { host, sessions: BTreeMap::new() }) } pub fn ensure( &mut self, request: ServoLiveEnsureRequest, ) -> Result, ServoLiveError> { - #[cfg(target_os = "macos")] - self.drain_iosurface_imports()?; - let ready_surface_ids = self.ready_surface_ids(); - self.request(LiveRequest::Ensure { - tab_id: request.tab_id, - profile_id: request.profile_id, - url: request.url, - width: request.width, - height: request.height, - page_zoom_percent: request.page_zoom_percent, - device_pixel_ratio: request.device_pixel_ratio, - scroll_delta_x: request.scroll_delta_x, - scroll_delta_y: request.scroll_delta_y, - scroll_point_x: request.scroll_point_x, - scroll_point_y: request.scroll_point_y, - click_x: request.click_x, - click_y: request.click_y, - hover_x: request.hover_x, - hover_y: request.hover_y, - typed_text: request.typed_text, - site_permissions: request.site_permissions, - ready_surface_ids, - }) + let tab_id = TabId::parse(request.tab_id.clone())?; + let profile_id = ProfileId::parse(request.profile_id.clone())?; + let requested_url = UrlText::parse(request.url.clone())?; + let webview_id = self.ensure_webview(&request, &tab_id, &profile_id)?; + + self.apply_viewport(&request, &webview_id)?; + self.apply_permissions(&request, &webview_id, &profile_id)?; + self.apply_navigation(&request, &webview_id, tab_id, requested_url)?; + self.apply_input(&request, &webview_id)?; + self.host.tick(); + if !self.session_uses_native_surface(&request.tab_id) { + return Err(ServoLiveError::NativeSurfaceUnavailable); + } + self.host.paint_without_readback_with_completion(&webview_id, false)?; + + let frame = self.frame_from_session(&request.tab_id, &webview_id)?; + Ok(Some(frame)) } pub fn poll(&mut self, tab_id: String) -> Result, ServoLiveError> { - #[cfg(target_os = "macos")] - self.drain_iosurface_imports()?; - let ready_surface_ids = self.ready_surface_ids(); - self.request(LiveRequest::Poll { tab_id, ready_surface_ids }) + let Some(session) = self.sessions.get(&tab_id) else { + return Ok(None); + }; + let webview_id = session.webview_id.clone(); + let uses_native_surface = session.native_surface_id.is_some(); + + self.host.tick(); + if !self.host.snapshot(&webview_id)?.has_pending_frame() { + return Ok(None); + } + + if !uses_native_surface { + return Err(ServoLiveError::NativeSurfaceUnavailable); + } + self.host.paint_without_readback_with_completion(&webview_id, false)?; + self.frame_from_session(&tab_id, &webview_id).map(Some) } pub fn close(&mut self, tab_id: String) -> Result<(), ServoLiveError> { - self.request(LiveRequest::Close { tab_id }).map(|_| ()) - } - - fn request(&mut self, request: LiveRequest) -> Result, ServoLiveError> { - serde_json::to_writer(&mut self.stdin, &request)?; - self.stdin.write_all(b"\n").map_err(ServoLiveError::Command)?; - self.stdin.flush().map_err(ServoLiveError::Command)?; - - let mut line = String::new(); - let bytes = self.stdout.read_line(&mut line).map_err(ServoLiveError::Command)?; - if bytes == 0 { - return Err(ServoLiveError::SidecarExited); - } - - let response: LiveResponse = serde_json::from_str(&line)?; - if let Some(error) = response.error { - return Err(ServoLiveError::SidecarFailed { message: error }); - } - let surface_handle = response.surface_handle; - let current_surface_id = response.current_surface_id; - - if let Some(perf) = response.perf.as_ref() { - log_frame_perf(perf); - } - - if let Some(handle) = surface_handle.as_ref() { - log_iosurface_handle(handle); - } - if let Some(surface_id) = current_surface_id { - log_iosurface_current(surface_id); - } - - let Some(report) = response.frame else { - return Ok(None); - }; - - // Sanity bound the byte count advertised by the sidecar - // header so a buggy or hostile sidecar can't park us on - // `read_exact` for an arbitrarily-sized buffer. The honest - // upper limit is `width * height * 4` (RGBA8); `0` is the - // explicit "hardware path active, sample the IOSurface" - // signal; any other byte count is a protocol violation. - let pixel_byte_count = - (report.width as u64).saturating_mul(report.height as u64).saturating_mul(4); - let advertised = report.rgba_byte_count as u64; - if advertised != 0 && advertised != pixel_byte_count { - return Err(ServoLiveError::FrameBudgetExceeded { - advertised: report.rgba_byte_count, - pixel_budget: pixel_byte_count, - width: report.width, - height: report.height, - }); - } - - // Raw frame bytes follow the JSON header on the same pipe for - // software frames. `read_exact` drains BufReader's buffer first - // (the line read never crosses the `\n` boundary) and then - // pulls the rest straight from the child's stdout. - let mut rgba_bytes = vec![0u8; report.rgba_byte_count]; - if report.rgba_byte_count > 0 { - self.stdout.read_exact(&mut rgba_bytes).map_err(ServoLiveError::FrameRead)?; - } - - let has_software_payload = report.rgba_byte_count > 0; - let mut frame = ServoLiveFrame::from_parts(report, rgba_bytes); - - #[cfg(target_os = "macos")] - if let Some(handle) = surface_handle.as_ref() { - self.queue_iosurface_handle(*handle)?; - self.drain_iosurface_imports()?; - } - - #[cfg(target_os = "macos")] - if let Some(surface_id) = current_surface_id { - let pixel_buffer = self.iosurface_cache.pixel_buffer_for(surface_id); - if pixel_buffer.is_none() && !has_software_payload { - return Ok(None); - } - frame.set_pixel_buffer(pixel_buffer); - } - - Ok(Some(frame)) - } -} - -#[cfg(not(target_os = "macos"))] -impl ServoLiveClient { - fn ready_surface_ids(&self) -> Vec { - Vec::new() - } -} - -#[cfg(target_os = "macos")] -impl ServoLiveClient { - fn ready_surface_ids(&self) -> Vec { - self.iosurface_cache.surface_ids() - } - - fn queue_iosurface_handle(&mut self, handle: LiveSurfaceHandle) -> Result<(), ServoLiveError> { - let Some(importer) = self.iosurface_importer.as_ref() else { - return Err(ServoLiveError::IOSurfaceImportFailed { - surface_id: handle.surface_id, - mach_port_name: handle.mach_port_name, - message: "IOSurface import worker is unavailable".to_string(), - }); - }; - importer.submit(handle).map_err(|failure| ServoLiveError::IOSurfaceImportFailed { - surface_id: failure.surface_id, - mach_port_name: failure.mach_port_name, - message: failure.message, - }) - } - - fn drain_iosurface_imports(&mut self) -> Result<(), ServoLiveError> { - let Some(importer) = self.iosurface_importer.as_ref() else { + let Some(session) = self.sessions.remove(&tab_id) else { return Ok(()); }; - for result in importer.drain() { - match result { - IOSurfaceImportResult::Imported(imported) => { - self.iosurface_cache - .insert_pixel_buffer(imported.surface_id, imported.pixel_buffer); - tracing::info!( - target: "ely::servo::iosurface", - surface_id = imported.surface_id, - width = imported.width, - height = imported.height, - "imported IOSurface into CVPixelBuffer cache", - ); - } - IOSurfaceImportResult::Failed(failure) => { - tracing::warn!( - target: "ely::servo::iosurface", - surface_id = failure.surface_id, - width = failure.width, - height = failure.height, - mach_port_name = failure.mach_port_name, - message = %failure.message, - "IOSurface import worker failed", - ); - return Err(ServoLiveError::IOSurfaceImportFailed { - surface_id: failure.surface_id, - mach_port_name: failure.mach_port_name, - message: failure.message, - }); - } + self.host.close_webview(&session.webview_id); + Ok(()) + } + + fn ensure_webview( + &mut self, + request: &ServoLiveEnsureRequest, + tab_id: &TabId, + profile_id: &ProfileId, + ) -> Result { + if self + .sessions + .get(&request.tab_id) + .is_some_and(|session| session.profile_id != *profile_id) + && let Some(session) = self.sessions.remove(&request.tab_id) + { + self.host.close_webview(&session.webview_id); + } + + let native_surface_id = + request.native_surface.as_ref().map(gpui::NativeSurfaceHandle::identity); + if self + .sessions + .get(&request.tab_id) + .is_some_and(|session| session.native_surface_id != native_surface_id) + && let Some(session) = self.sessions.remove(&request.tab_id) + { + self.host.close_webview(&session.webview_id); + } + + if let Some(session) = self.sessions.get(&request.tab_id) { + return Ok(session.webview_id.clone()); + } + + let surface_size = ServoSurfaceSize::new(request.width, request.height); + let webview_id = match request.native_surface.as_ref() { + Some(native_surface) => self.host.create_webview_with_native_surface( + tab_id.clone(), + profile_id.clone(), + surface_size, + native_surface, + )?, + None => self.host.create_webview_with_size( + tab_id.clone(), + profile_id.clone(), + surface_size, + )?, + }; + self.sessions.insert( + request.tab_id.clone(), + DirectWebViewSession { + webview_id: webview_id.clone(), + profile_id: profile_id.clone(), + requested_url: None, + width: request.width, + height: request.height, + page_zoom_percent: request.page_zoom_percent, + device_pixel_ratio: request.device_pixel_ratio, + native_surface_id, + }, + ); + Ok(webview_id) + } + + fn apply_viewport( + &mut self, + request: &ServoLiveEnsureRequest, + webview_id: &WebViewId, + ) -> Result<(), ServoLiveError> { + let Some(session) = self.sessions.get_mut(&request.tab_id) else { + return Ok(()); + }; + + if session.width != request.width || session.height != request.height { + self.host.resize(ResizeRequest { + webview_id: webview_id.clone(), + width: request.width, + height: request.height, + })?; + session.width = request.width; + session.height = request.height; + } + + if session.device_pixel_ratio != request.device_pixel_ratio { + self.host.set_hidpi_scale(HidpiScaleRequest { + webview_id: webview_id.clone(), + scale_factor: request.device_pixel_ratio, + })?; + session.device_pixel_ratio = request.device_pixel_ratio; + } + + if session.page_zoom_percent != request.page_zoom_percent { + self.host.set_page_zoom(PageZoomRequest { + webview_id: webview_id.clone(), + zoom_factor: f32::from(request.page_zoom_percent) / 100.0, + })?; + session.page_zoom_percent = request.page_zoom_percent; + } + + Ok(()) + } + + fn apply_permissions( + &mut self, + request: &ServoLiveEnsureRequest, + webview_id: &WebViewId, + profile_id: &ProfileId, + ) -> Result<(), ServoLiveError> { + for permission in &request.site_permissions { + let origin = SiteOrigin::parse(permission.origin.clone())?; + let feature = SitePermissionFeature::parse(permission.feature.as_str())?; + let decision = SitePermissionDecision::parse(permission.decision.as_str())?; + self.host.set_permission( + PermissionRequest { + webview_id: webview_id.clone(), + profile_id: profile_id.clone(), + origin, + feature, + }, + PermissionDecision::from(decision), + )?; + } + Ok(()) + } + + fn apply_navigation( + &mut self, + request: &ServoLiveEnsureRequest, + webview_id: &WebViewId, + tab_id: TabId, + requested_url: UrlText, + ) -> Result<(), ServoLiveError> { + let should_navigate = self + .sessions + .get(&request.tab_id) + .and_then(|session| session.requested_url.as_deref()) + .is_none_or(|current| current != requested_url.as_str()); + if should_navigate { + self.host.navigate(NavigationRequest { + webview_id: webview_id.clone(), + tab_id, + url: requested_url.clone(), + })?; + if let Some(session) = self.sessions.get_mut(&request.tab_id) { + session.requested_url = Some(requested_url.as_str().to_string()); } } Ok(()) } -} -impl Drop for ServoLiveClient { - fn drop(&mut self) { - let _ = self.child.kill(); - let _ = self.child.wait(); + fn apply_input( + &mut self, + request: &ServoLiveEnsureRequest, + webview_id: &WebViewId, + ) -> Result<(), ServoLiveError> { + if request.scroll_delta_x != 0 || request.scroll_delta_y != 0 { + let point_x = request.scroll_point_x.ok_or(ServoLiveError::MissingScrollPoint)?; + let point_y = request.scroll_point_y.ok_or(ServoLiveError::MissingScrollPoint)?; + self.host.scroll(ScrollRequest { + webview_id: webview_id.clone(), + delta_x: request.scroll_delta_x, + delta_y: request.scroll_delta_y, + point_x, + point_y, + })?; + } + + if let (Some(x), Some(y)) = (request.hover_x, request.hover_y) { + self.host.hover(MouseHoverRequest { webview_id: webview_id.clone(), x, y })?; + } + + if let (Some(x), Some(y)) = (request.click_x, request.click_y) { + self.host.click(MouseClickRequest { webview_id: webview_id.clone(), x, y })?; + } + + if let Some(text) = request.typed_text.as_ref() { + self.host.type_text(KeyboardTextRequest { + webview_id: webview_id.clone(), + text: text.clone(), + })?; + } + + Ok(()) + } + + fn frame_from_session( + &self, + tab_id: &str, + webview_id: &WebViewId, + ) -> Result { + let Some(session) = self.sessions.get(tab_id) else { + return Err(ServoLiveError::Host(ely_servo_host::ServoHostError::WebViewNotFound { + id: webview_id.clone(), + })); + }; + if session.native_surface_id.is_some() { + let snapshot = self.host.snapshot(webview_id)?; + return Ok(ServoLiveFrame::from_presented( + snapshot, + session.width, + session.height, + session.device_pixel_ratio, + )); + } + Err(ServoLiveError::NativeSurfaceUnavailable) + } + + fn session_uses_native_surface(&self, tab_id: &str) -> bool { + self.sessions.get(tab_id).is_some_and(|session| session.native_surface_id.is_some()) } } + +struct DirectWebViewSession { + webview_id: WebViewId, + profile_id: ProfileId, + requested_url: Option, + width: u32, + height: u32, + page_zoom_percent: u16, + device_pixel_ratio: f32, + native_surface_id: Option, +} diff --git a/crates/ely_app/src/services/servo_live_iosurface_importer.rs b/crates/ely_app/src/services/servo_live_iosurface_importer.rs deleted file mode 100644 index 4a208e6..0000000 --- a/crates/ely_app/src/services/servo_live_iosurface_importer.rs +++ /dev/null @@ -1,147 +0,0 @@ -#![cfg(target_os = "macos")] - -use std::{ - io, - sync::mpsc, - thread::{self, JoinHandle}, - time::Duration, -}; - -use core_video::pixel_buffer::CVPixelBuffer; - -use crate::services::{ - iosurface_mach::IOSurfaceMachReceiver, iosurface_metal::import_pixel_buffer_from_mach_port, -}; - -use super::wire::LiveSurfaceHandle; - -const RECEIVE_TIMEOUT: Duration = Duration::from_secs(1); - -pub(super) struct IOSurfaceImportWorker { - request_tx: Option>, - result_rx: mpsc::Receiver, - thread: Option>, -} - -impl IOSurfaceImportWorker { - pub(super) fn new(receiver: IOSurfaceMachReceiver) -> Result { - let (request_tx, request_rx) = mpsc::channel(); - let (result_tx, result_rx) = mpsc::channel(); - let thread = thread::Builder::new() - .name("ely-iosurface-import".to_string()) - .spawn(move || run_import_worker(receiver, request_rx, result_tx))?; - - Ok(Self { request_tx: Some(request_tx), result_rx, thread: Some(thread) }) - } - - pub(super) fn submit(&self, handle: LiveSurfaceHandle) -> Result<(), IOSurfaceImportFailure> { - let Some(request_tx) = self.request_tx.as_ref() else { - return Err(IOSurfaceImportFailure::worker_stopped(handle)); - }; - request_tx.send(handle).map_err(|error| IOSurfaceImportFailure::worker_stopped(error.0)) - } - - pub(super) fn drain(&self) -> Vec { - let mut results = Vec::new(); - while let Ok(result) = self.result_rx.try_recv() { - results.push(result); - } - results - } -} - -impl Drop for IOSurfaceImportWorker { - fn drop(&mut self) { - self.request_tx.take(); - if let Some(thread) = self.thread.take() { - let _ = thread.join(); - } - } -} - -pub(super) enum IOSurfaceImportResult { - Imported(ImportedIOSurface), - Failed(IOSurfaceImportFailure), -} - -pub(super) struct ImportedIOSurface { - pub(super) surface_id: u64, - pub(super) width: u32, - pub(super) height: u32, - pub(super) pixel_buffer: CVPixelBuffer, -} - -// SAFETY: CVPixelBuffer is a CoreFoundation object with atomic -// retain/release semantics. This wrapper crosses from the importer -// thread to the live worker thread; GPUI presentation already receives -// the same handle through ServoLiveFrame's Send contract. -#[expect(unsafe_code)] -unsafe impl Send for ImportedIOSurface {} - -pub(super) struct IOSurfaceImportFailure { - pub(super) surface_id: u64, - pub(super) width: u32, - pub(super) height: u32, - pub(super) mach_port_name: u32, - pub(super) message: String, -} - -impl IOSurfaceImportFailure { - fn worker_stopped(handle: LiveSurfaceHandle) -> Self { - Self { - surface_id: handle.surface_id, - width: handle.width, - height: handle.height, - mach_port_name: handle.mach_port_name, - message: "IOSurface import worker stopped".to_string(), - } - } -} - -fn run_import_worker( - mut receiver: IOSurfaceMachReceiver, - request_rx: mpsc::Receiver, - result_tx: mpsc::Sender, -) { - while let Ok(handle) = request_rx.recv() { - let result = import_surface_handle(&mut receiver, handle); - if result_tx.send(result).is_err() { - return; - } - } -} - -fn import_surface_handle( - receiver: &mut IOSurfaceMachReceiver, - handle: LiveSurfaceHandle, -) -> IOSurfaceImportResult { - let mach_port_name = match receiver.receive_port_for_surface(handle.surface_id, RECEIVE_TIMEOUT) - { - Ok(mach_port_name) => mach_port_name, - Err(error) => { - return IOSurfaceImportResult::Failed(IOSurfaceImportFailure { - surface_id: handle.surface_id, - width: handle.width, - height: handle.height, - mach_port_name: handle.mach_port_name, - message: error.to_string(), - }); - } - }; - - match import_pixel_buffer_from_mach_port(mach_port_name) { - Ok(pixel_buffer) => IOSurfaceImportResult::Imported(ImportedIOSurface { - surface_id: handle.surface_id, - width: handle.width, - height: handle.height, - pixel_buffer, - }), - Err(error) => IOSurfaceImportResult::Failed(IOSurfaceImportFailure { - surface_id: handle.surface_id, - width: handle.width, - height: handle.height, - mach_port_name, - message: error.to_string(), - }), - } -} diff --git a/crates/ely_app/src/services/servo_live_types.rs b/crates/ely_app/src/services/servo_live_types.rs index db9f0e4..145c164 100644 --- a/crates/ely_app/src/services/servo_live_types.rs +++ b/crates/ely_app/src/services/servo_live_types.rs @@ -1,17 +1,9 @@ -use std::{io, path::PathBuf}; - use ely_domain::SitePermissionDecision; +use ely_servo_host::{ServoHostError, WebViewSnapshot, WebViewState}; +use gpui::NativeSurfaceHandle; use serde::Serialize; use thiserror::Error; -use super::wire::LiveFrameReport; -use crate::services::servo_sidecar_command::SidecarCommandError; - -#[cfg(target_os = "macos")] -use crate::services::iosurface_mach::IOSurfaceMachError; -#[cfg(target_os = "macos")] -use core_video::pixel_buffer::CVPixelBuffer; - pub(crate) struct ServoLiveEnsureRequest { pub(crate) tab_id: String, pub(crate) profile_id: String, @@ -20,10 +12,9 @@ pub(crate) struct ServoLiveEnsureRequest { pub(crate) height: u32, pub(crate) page_zoom_percent: u16, /// Display scale factor (1.0 standard, 2.0 Retina). Servo's - /// WebView lays out CSS pixels = device pixels / hidpi factor; - /// without this, a Retina viewport gets desktop-CSS-pixel layout - /// and every visible element renders at half its expected size. + /// WebView lays out CSS pixels = device pixels / hidpi factor. pub(crate) device_pixel_ratio: f32, + pub(crate) native_surface: Option, pub(crate) scroll_delta_x: i32, pub(crate) scroll_delta_y: i32, pub(crate) scroll_point_x: Option, @@ -68,54 +59,37 @@ pub(crate) struct ServoLiveFrame { content_pixel_count: u64, #[cfg(all(test, feature = "live-site-smoke"))] sample_hash: u64, - rgba_bytes: Vec, - #[cfg(target_os = "macos")] - pixel_buffer: Option, + rgba_bytes: Option>, } -// SAFETY: CVPixelBuffer wraps CVPixelBufferRef, a CoreFoundation type -// Apple documents as safe to share across threads. The Rust core-video -// crate does not mark it Send, so the worker thread needs this opt-in -// to ship hardware frames back to the UI thread via mpsc::Sender. -#[cfg(target_os = "macos")] -#[expect(unsafe_code)] -unsafe impl Send for ServoLiveFrame {} - impl ServoLiveFrame { - pub(super) fn from_parts(report: LiveFrameReport, rgba_bytes: Vec) -> Self { - let (css_viewport_width, css_viewport_height) = css_viewport_size_from_report(&report); + pub(super) fn from_presented( + snapshot: WebViewSnapshot, + width: u32, + height: u32, + device_pixel_ratio: f32, + ) -> Self { + let (css_viewport_width, css_viewport_height) = + css_viewport_size(width, height, device_pixel_ratio); Self { - loaded_url: report.loaded_url, - title: report.title, - render_state: report.state, - width: report.width, - height: report.height, - device_pixel_ratio: report.device_pixel_ratio, + loaded_url: snapshot.url().map(str::to_string), + title: snapshot.title().map(str::to_string), + render_state: render_state_label(snapshot.state()).to_string(), + width, + height, + device_pixel_ratio, css_viewport_width, css_viewport_height, #[cfg(all(test, feature = "live-site-smoke"))] - non_white_pixel_count: report.non_white_pixel_count, + non_white_pixel_count: 1, #[cfg(all(test, feature = "live-site-smoke"))] - content_pixel_count: report.content_pixel_count, + content_pixel_count: 1, #[cfg(all(test, feature = "live-site-smoke"))] - sample_hash: report.sample_hash, - rgba_bytes, - #[cfg(target_os = "macos")] - pixel_buffer: None, + sample_hash: 0, + rgba_bytes: None, } } - #[cfg(target_os = "macos")] - pub(super) fn set_pixel_buffer(&mut self, pixel_buffer: Option) { - self.pixel_buffer = pixel_buffer; - } - - #[cfg(target_os = "macos")] - #[must_use] - pub fn pixel_buffer(&self) -> Option<&CVPixelBuffer> { - self.pixel_buffer.as_ref() - } - #[must_use] pub fn loaded_url(&self) -> Option<&str> { self.loaded_url.as_deref() @@ -175,7 +149,7 @@ impl ServoLiveFrame { } #[must_use] - pub fn into_rgba_bytes(self) -> Vec { + pub fn into_rgba_bytes(self) -> Option> { self.rgba_bytes } @@ -196,113 +170,50 @@ impl ServoLiveFrame { content_pixel_count: 0, #[cfg(all(test, feature = "live-site-smoke"))] sample_hash: 0, - rgba_bytes, - #[cfg(target_os = "macos")] - pixel_buffer: None, - } - } - - #[cfg(all(test, target_os = "macos"))] - pub(crate) fn for_test_with_pixel_buffer( - width: u32, - height: u32, - pixel_buffer: CVPixelBuffer, - ) -> Self { - Self { - loaded_url: Some("https://example.com/".to_string()), - title: Some("Example".to_string()), - render_state: "complete".to_string(), - width, - height, - device_pixel_ratio: 1.0, - css_viewport_width: width, - css_viewport_height: height, - #[cfg(all(test, feature = "live-site-smoke"))] - non_white_pixel_count: 0, - #[cfg(all(test, feature = "live-site-smoke"))] - content_pixel_count: 0, - #[cfg(all(test, feature = "live-site-smoke"))] - sample_hash: 0, - rgba_bytes: Vec::new(), - pixel_buffer: Some(pixel_buffer), + rgba_bytes: Some(rgba_bytes), } } } -fn css_viewport_size_from_report(report: &LiveFrameReport) -> (u32, u32) { - let dpr = if report.device_pixel_ratio.is_finite() && report.device_pixel_ratio > 0.0 { - report.device_pixel_ratio +fn render_state_label(state: &WebViewState) -> &'static str { + match state { + WebViewState::Created => "created", + WebViewState::Loading => "loading", + WebViewState::Complete => "complete", + WebViewState::Sleeping => "sleeping", + WebViewState::Crashed => "crashed", + } +} + +fn css_viewport_size(width: u32, height: u32, device_pixel_ratio: f32) -> (u32, u32) { + let scale = if device_pixel_ratio.is_finite() && device_pixel_ratio > 0.0 { + device_pixel_ratio } else { 1.0 }; - let fallback_width = ((report.width as f32) / dpr).round().max(1.0) as u32; - let fallback_height = ((report.height as f32) / dpr).round().max(1.0) as u32; ( - if report.css_viewport_width > 0 { report.css_viewport_width } else { fallback_width }, - if report.css_viewport_height > 0 { report.css_viewport_height } else { fallback_height }, + ((width as f32) / scale).round().max(1.0) as u32, + ((height as f32) / scale).round().max(1.0) as u32, ) } #[derive(Debug, Error)] pub(crate) enum ServoLiveError { - #[error("servo sidecar binary is unavailable at {path}")] - SidecarBinaryUnavailable { path: PathBuf }, + #[error("servo native surface is unavailable")] + NativeSurfaceUnavailable, - #[error("failed to run servo live sidecar: {0}")] - Command(#[source] io::Error), - - #[error("servo live sidecar pipe is unavailable: {name}")] - PipeUnavailable { name: &'static str }, - - #[error("servo live sidecar exited")] - SidecarExited, - - #[error("servo live sidecar failed: {message}")] - SidecarFailed { message: String }, - - #[error("failed to read servo live frame bytes: {0}")] - FrameRead(#[source] io::Error), - - #[error( - "servo live sidecar advertised {advertised} frame bytes which exceeds \ - the {width}x{height} pixel budget ({pixel_budget} bytes)" - )] - FrameBudgetExceeded { advertised: usize, pixel_budget: u64, width: u32, height: u32 }, - - #[cfg(target_os = "macos")] - #[error( - "servo live IOSurface import failed for surface {surface_id:#x} \ - mach port 0x{mach_port_name:x}: {message}" - )] - IOSurfaceImportFailed { surface_id: u64, mach_port_name: u32, message: String }, - - #[cfg(target_os = "macos")] - #[error("failed to spawn servo live IOSurface importer: {0}")] - IOSurfaceImportWorker(#[source] io::Error), - - #[cfg(target_os = "macos")] - #[error(transparent)] - IOSurfaceMach(#[from] IOSurfaceMachError), + #[error("servo scroll input is missing a viewport point")] + MissingScrollPoint, #[error(transparent)] - Json(#[from] serde_json::Error), + Domain(#[from] ely_domain::DomainError), #[error(transparent)] - SidecarCommand(#[from] SidecarCommandError), + Host(#[from] ServoHostError), } impl ServoLiveError { - pub(crate) fn is_sidecar_process_unusable(&self) -> bool { - match self { - Self::SidecarExited => true, - Self::Command(error) | Self::FrameRead(error) => matches!( - error.kind(), - io::ErrorKind::BrokenPipe - | io::ErrorKind::ConnectionAborted - | io::ErrorKind::ConnectionReset - | io::ErrorKind::UnexpectedEof - ), - _ => false, - } + pub(crate) fn is_runtime_unavailable(&self) -> bool { + matches!(self, Self::Host(ServoHostError::RuntimeAlreadyStarted)) } } diff --git a/crates/ely_app/src/services/servo_live_wire.rs b/crates/ely_app/src/services/servo_live_wire.rs deleted file mode 100644 index 226b8a4..0000000 --- a/crates/ely_app/src/services/servo_live_wire.rs +++ /dev/null @@ -1,189 +0,0 @@ -use serde::{Deserialize, Serialize}; - -use super::ServoLiveSitePermission; - -#[derive(Serialize)] -#[serde(tag = "type", rename_all = "snake_case")] -pub(super) enum LiveRequest { - Ensure { - tab_id: String, - profile_id: String, - url: String, - width: u32, - height: u32, - page_zoom_percent: u16, - device_pixel_ratio: f32, - scroll_delta_x: i32, - scroll_delta_y: i32, - scroll_point_x: Option, - scroll_point_y: Option, - click_x: Option, - click_y: Option, - hover_x: Option, - hover_y: Option, - typed_text: Option, - site_permissions: Vec, - ready_surface_ids: Vec, - }, - Poll { - tab_id: String, - ready_surface_ids: Vec, - }, - Close { - tab_id: String, - }, -} - -#[derive(Deserialize)] -pub(super) struct LiveResponse { - pub(super) error: Option, - pub(super) frame: Option, - #[serde(default)] - pub(super) perf: Option, - /// Hardware path only: present on the first frame after a new - /// IOSurface is bound (initial paint, resize, surfman swap chain - /// rotation). T10.4 will turn this into an imported Metal texture; - /// for now we log it on the `ely::servo::iosurface` target so the - /// pipeline is observable end-to-end without yet wiring it into - /// the renderer. - #[serde(default)] - pub(super) surface_handle: Option, - /// Hardware path only: which previously-imported IOSurface to - /// sample this frame. surfman's attached swap chain rotates the - /// bound surface, so this id alternates between the values the - /// receiver has already imported via `surface_handle`. - #[serde(default)] - pub(super) current_surface_id: Option, -} - -/// Wire mirror of `ely_servo_host::IOSurfaceHandle`. Duplicated rather -/// than imported because `ely_app` only talks to the sidecar via -/// stdin/stdout JSON — it has no crate dependency on `ely_servo_host` -/// and adding one just to share a four-field struct would pull the -/// Servo dep tree into the renderer process. -#[derive(Clone, Copy, Debug, Deserialize)] -pub(super) struct LiveSurfaceHandle { - pub(super) mach_port_name: u32, - pub(super) surface_id: u64, - pub(super) width: u32, - pub(super) height: u32, -} - -/// Aggregated frame-stage timings rolled up every N frames by the -/// sidecar. We accept anything matching the wire shape and let the -/// `tracing` event echo the percentiles verbatim — the sidecar is -/// the source of truth for histogram boundaries. -#[derive(Deserialize)] -pub(super) struct LiveFramePerfSummary { - window: u32, - context: String, - paint_p50_us: u64, - paint_p95_us: u64, - paint_p99_us: u64, - encode_p50_us: u64, - encode_p95_us: u64, - encode_p99_us: u64, - write_p50_us: u64, - write_p95_us: u64, - write_p99_us: u64, - total_p50_us: u64, - total_p95_us: u64, - total_p99_us: u64, -} - -#[derive(Deserialize)] -pub(super) struct LiveFrameReport { - pub(super) loaded_url: Option, - pub(super) title: Option, - pub(super) state: String, - pub(super) width: u32, - pub(super) height: u32, - #[serde(default = "default_device_pixel_ratio")] - pub(super) device_pixel_ratio: f32, - #[serde(default)] - pub(super) css_viewport_width: u32, - #[serde(default)] - pub(super) css_viewport_height: u32, - pub(super) rgba_byte_count: usize, - #[cfg(all(test, feature = "live-site-smoke"))] - pub(super) non_white_pixel_count: u64, - #[cfg(all(test, feature = "live-site-smoke"))] - pub(super) content_pixel_count: u64, - #[cfg(all(test, feature = "live-site-smoke"))] - pub(super) sample_hash: u64, -} - -fn default_device_pixel_ratio() -> f32 { - 1.0 -} - -/// Per-frame tag that tells the renderer which already-imported -/// `MTLTexture` to sample. Emitted at `trace` instead of `info` because -/// it fires every frame on the hardware path; the import event above -/// is the rare `info` and this trace is the steady-state breadcrumb. -pub(super) fn log_iosurface_current(surface_id: u64) { - tracing::trace!( - target: "ely::servo::iosurface", - surface_id, - "iosurface_current", - ); -} - -/// Emit one structured `tracing` event per IOSurface handover, on a -/// dedicated target so `RUST_LOG=ely::servo::iosurface=info` lights up -/// the cross-process surface pipeline without pulling in everything -/// else. The renderer (T10.4) will turn the same handle into an -/// imported Metal texture; today the event is the observable contract -/// that T10.3 plumbing is alive. -pub(super) fn log_iosurface_handle(handle: &LiveSurfaceHandle) { - tracing::info!( - target: "ely::servo::iosurface", - mach_port_name = handle.mach_port_name, - surface_id = handle.surface_id, - width = handle.width, - height = handle.height, - "iosurface_handle", - ); -} - -/// Emit one structured `tracing` event per perf summary, on a -/// dedicated target so `RUST_LOG=ely::servo::perf=info` flips the -/// stream on without dragging the rest of the app along. Filtering -/// happens upstream in the subscriber — this call is a single -/// pointer + integer push. -pub(super) fn log_frame_perf(summary: &LiveFramePerfSummary) { - tracing::info!( - target: "ely::servo::perf", - window = summary.window, - context = %summary.context, - paint_p50_us = summary.paint_p50_us, - paint_p95_us = summary.paint_p95_us, - paint_p99_us = summary.paint_p99_us, - encode_p50_us = summary.encode_p50_us, - encode_p95_us = summary.encode_p95_us, - encode_p99_us = summary.encode_p99_us, - write_p50_us = summary.write_p50_us, - write_p95_us = summary.write_p95_us, - write_p99_us = summary.write_p99_us, - total_p50_us = summary.total_p50_us, - total_p95_us = summary.total_p95_us, - total_p99_us = summary.total_p99_us, - "frame_perf", - ); -} - -#[cfg(test)] -mod tests { - use serde_json::json; - - use super::*; - - #[test] - fn close_request_serializes_to_wire() -> Result<(), serde_json::Error> { - let value = - serde_json::to_value(LiveRequest::Close { tab_id: "tab-live-close".to_string() })?; - - assert_eq!(value, json!({"type": "close", "tab_id": "tab-live-close"})); - Ok(()) - } -} diff --git a/crates/ely_app/src/services/servo_sidecar_command.rs b/crates/ely_app/src/services/servo_sidecar_command.rs deleted file mode 100644 index 4005335..0000000 --- a/crates/ely_app/src/services/servo_sidecar_command.rs +++ /dev/null @@ -1,220 +0,0 @@ -use std::{ - env, io, - path::{Path, PathBuf}, - process::Command, -}; - -use thiserror::Error; - -const SIDECAR_PATH_ENV: &str = "ELY_SERVO_SIDECAR"; -const RENDERING_CONTEXT_ENV: &str = "ELY_SERVO_RENDERING_CONTEXT"; -const SOFTWARE_SIDECAR_FEATURES: &str = "servo-engine"; -const HARDWARE_SIDECAR_FEATURES: &str = "servo-engine,hardware-render"; - -#[derive(Clone, Debug)] -pub(super) enum SidecarCommandTarget { - Binary(PathBuf), - Cargo { manifest_path: PathBuf }, -} - -impl SidecarCommandTarget { - pub(super) fn command(&self) -> Command { - match self { - Self::Binary(path) => Command::new(path), - Self::Cargo { manifest_path } => { - let mut command = Command::new("cargo"); - command - .arg("run") - .arg("--quiet") - .arg("--manifest-path") - .arg(manifest_path) - .arg("-p") - .arg("ely_servo_host") - .arg("--features") - .arg(sidecar_features_from_env()) - .arg("--bin") - .arg("ely_servo_sidecar") - .arg("--"); - command - } - } - } - - pub(super) fn missing_binary_path(&self) -> Option<&Path> { - match self { - Self::Binary(path) if !path.is_file() => Some(path.as_path()), - Self::Binary(_) | Self::Cargo { .. } => None, - } - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub(super) enum SidecarRenderingContext { - Software, - Hardware, -} - -impl SidecarRenderingContext { - pub(super) fn cli_arg(self) -> &'static str { - match self { - Self::Software => "software", - Self::Hardware => "hardware", - } - } - - fn sidecar_features(self) -> &'static str { - match self { - Self::Software => SOFTWARE_SIDECAR_FEATURES, - Self::Hardware => HARDWARE_SIDECAR_FEATURES, - } - } -} - -#[derive(Debug, Error)] -pub(crate) enum SidecarCommandError { - #[error("current executable path is unavailable: {0}")] - CurrentExecutable(#[source] io::Error), - - #[error("current executable directory is unavailable for {path}")] - CurrentExecutableDirectoryUnavailable { path: PathBuf }, -} - -pub(super) fn default_sidecar_command() -> Result { - if let Some(path) = env::var_os(SIDECAR_PATH_ENV) { - return Ok(SidecarCommandTarget::Binary(PathBuf::from(path))); - } - - let current_exe = env::current_exe().map_err(SidecarCommandError::CurrentExecutable)?; - let exe_dir = current_exe.parent().ok_or_else(|| { - SidecarCommandError::CurrentExecutableDirectoryUnavailable { path: current_exe.clone() } - })?; - let adjacent_sidecar = exe_dir.join(sidecar_binary_name()); - if adjacent_sidecar.is_file() && is_macos_app_bundle_exe_dir(exe_dir) { - return Ok(SidecarCommandTarget::Binary(adjacent_sidecar)); - } - let workspace_manifest = workspace_manifest_path(); - let workspace_target_sidecar = - workspace_manifest.as_ref().and_then(|path| workspace_target_sidecar_path(path)); - let adjacent_is_workspace_target = - workspace_target_sidecar.as_ref().is_some_and(|path| path == &adjacent_sidecar); - let workspace_target_sidecar_exists = - workspace_target_sidecar.as_ref().is_some_and(|path| path.is_file()); - let prefer_cargo_hardware_sidecar = rendering_context_from_env() - == SidecarRenderingContext::Hardware - && (adjacent_is_workspace_target || workspace_target_sidecar_exists) - && workspace_manifest.as_ref().is_some_and(|path| path.is_file()); - if adjacent_sidecar.is_file() && !prefer_cargo_hardware_sidecar { - return Ok(SidecarCommandTarget::Binary(adjacent_sidecar)); - } - - if let Some(manifest_path) = workspace_manifest { - if let Some(target_sidecar) = workspace_target_sidecar - && target_sidecar.is_file() - && !prefer_cargo_hardware_sidecar - { - return Ok(SidecarCommandTarget::Binary(target_sidecar)); - } - if manifest_path.is_file() { - return Ok(SidecarCommandTarget::Cargo { manifest_path }); - } - } - - Ok(SidecarCommandTarget::Binary(adjacent_sidecar)) -} - -fn workspace_manifest_path() -> Option { - option_env!("ELY_WORKSPACE_MANIFEST").map(PathBuf::from) -} - -pub(super) fn rendering_context_from_env() -> SidecarRenderingContext { - let raw = env::var(RENDERING_CONTEXT_ENV).ok(); - rendering_context_selection(raw.as_deref()) -} - -fn sidecar_features_from_env() -> &'static str { - rendering_context_from_env().sidecar_features() -} - -fn rendering_context_selection(raw: Option<&str>) -> SidecarRenderingContext { - match raw.map(str::to_lowercase).as_deref() { - Some("software") => SidecarRenderingContext::Software, - Some("hardware") => SidecarRenderingContext::Hardware, - _ => default_rendering_context(), - } -} - -fn default_rendering_context() -> SidecarRenderingContext { - if cfg!(target_os = "macos") { - SidecarRenderingContext::Hardware - } else { - SidecarRenderingContext::Software - } -} - -fn workspace_target_sidecar_path(manifest_path: &Path) -> Option { - let profile = if cfg!(debug_assertions) { "debug" } else { "release" }; - Some(manifest_path.parent()?.join("target").join(profile).join(sidecar_binary_name())) -} - -fn sidecar_binary_name() -> String { - format!("ely_servo_sidecar{}", env::consts::EXE_SUFFIX) -} - -fn is_macos_app_bundle_exe_dir(path: &Path) -> bool { - path.file_name().is_some_and(|name| name == "MacOS") - && path - .parent() - .is_some_and(|contents| contents.file_name().is_some_and(|name| name == "Contents")) - && path - .parent() - .and_then(Path::parent) - .is_some_and(|bundle| bundle.extension().is_some_and(|extension| extension == "app")) -} - -#[cfg(test)] -mod tests { - use super::{ - HARDWARE_SIDECAR_FEATURES, SOFTWARE_SIDECAR_FEATURES, SidecarRenderingContext, - is_macos_app_bundle_exe_dir, rendering_context_selection, - }; - - #[test] - fn hardware_rendering_context_enables_hardware_sidecar_feature() { - let context = rendering_context_selection(Some("hardware")); - assert_eq!(context, SidecarRenderingContext::Hardware); - assert_eq!(context.sidecar_features(), HARDWARE_SIDECAR_FEATURES); - assert_eq!( - rendering_context_selection(Some("HARDWARE")), - SidecarRenderingContext::Hardware - ); - } - - #[test] - fn software_rendering_context_uses_software_sidecar_feature() { - let context = rendering_context_selection(Some("software")); - assert_eq!(context, SidecarRenderingContext::Software); - assert_eq!(context.sidecar_features(), SOFTWARE_SIDECAR_FEATURES); - } - - #[test] - fn recognizes_macos_app_bundle_executable_directory() { - assert!(is_macos_app_bundle_exe_dir(std::path::Path::new( - "/tmp/ELY Browser.app/Contents/MacOS" - ))); - assert!(!is_macos_app_bundle_exe_dir(std::path::Path::new("/tmp/target/debug"))); - } - - #[cfg(target_os = "macos")] - #[test] - fn defaults_to_hardware_rendering_context_on_macos() { - assert_eq!(rendering_context_selection(None), SidecarRenderingContext::Hardware); - assert_eq!(rendering_context_selection(Some("garbage")), SidecarRenderingContext::Hardware); - } - - #[cfg(not(target_os = "macos"))] - #[test] - fn defaults_to_software_rendering_context_off_macos() { - assert_eq!(rendering_context_selection(None), SidecarRenderingContext::Software); - assert_eq!(rendering_context_selection(Some("garbage")), SidecarRenderingContext::Software); - } -} diff --git a/crates/ely_app/src/shell/gpui_harness_tests.rs b/crates/ely_app/src/shell/gpui_harness_tests.rs index a2c6ace..579f918 100644 --- a/crates/ely_app/src/shell/gpui_harness_tests.rs +++ b/crates/ely_app/src/shell/gpui_harness_tests.rs @@ -1,9 +1,9 @@ //! GPUI test harness for the input pipeline. //! -//! Twelve sidecar-side commits and one shell-side commit had all claimed to +//! Twelve renderer-side commits and one shell-side commit had all claimed to //! fix "click does nothing" while the user kept reporting the same symptom. //! The roundtable consensus: every store-layer test passed GREEN, every -//! sidecar integration test passed GREEN, but nothing in the repo exercised +//! renderer integration test passed GREEN, but nothing in the repo exercised //! the real GPUI event tree (`render_input_overlay` + window-level mouse //! handlers + sidebar capture interactions). This module is that missing //! holdout set. @@ -984,8 +984,7 @@ async fn baseline_overlay_div_receives_simulated_click(cx: &mut TestAppContext) /// **byte-identical RGBA payloads must produce the same underlying /// `Arc`**. Today they do not — every `from_live_frame` /// blindly reallocates. The fix path is either dedup the upload -/// against the last bytes or switch to `OffscreenRenderingContext` + -/// IOSurface so the GPU texture is the source of truth. +/// against the last bytes or switch to direct platform-surface presentation. /// /// Regression guard: with the single-slot `LAST_FRAME_IMAGE` cache in /// `web_surface_frame.rs`, two `ServoLiveFrame` inputs carrying @@ -1028,7 +1027,7 @@ fn identical_live_frames_share_render_image_arc() -> Result<(), String> { distinct Arc instances (first={:p}, second={:p}). \ WebSurfaceFrame::from_parts must dedup the upload against the \ previous frame's bytes, or the rendering pipeline must switch \ - to a GPU-side source of truth (IOSurface) so per-frame host \ + to direct platform-surface presentation so per-frame host \ allocations stop entirely.", Arc::as_ptr(first_image), Arc::as_ptr(second_image), diff --git a/crates/ely_app/src/shell/web_surface.rs b/crates/ely_app/src/shell/web_surface.rs index 0d72346..c674b2f 100644 --- a/crates/ely_app/src/shell/web_surface.rs +++ b/crates/ely_app/src/shell/web_surface.rs @@ -2,7 +2,7 @@ use std::collections::BTreeMap; use std::time::{Duration, Instant}; use ely_domain::{BrowserTab, TabId}; -use gpui::{Bounds, Pixels, Point}; +use gpui::{Bounds, NativeSurfaceHandle, Pixels, Point}; use crate::services::ProfileDataMode; @@ -56,8 +56,22 @@ impl WebSurfaceStore { else { return false; }; - let ensure_key = - WebSurfaceEnsureKey::new(requested_url.clone(), size, tab.zoom_percent(), permissions); + let native_surface = + self.surfaces.get(tab.id()).and_then(|surface| surface.native_surface.clone()); + #[cfg(not(test))] + let Some(native_surface) = native_surface else { + return false; + }; + let ensure_key = WebSurfaceEnsureKey::new( + requested_url.clone(), + size, + #[cfg(test)] + native_surface.as_ref(), + #[cfg(not(test))] + Some(&native_surface), + tab.zoom_percent(), + permissions, + ); if self.surfaces.get(tab.id()).is_some_and(|surface| !surface.should_ensure(&ensure_key)) { return false; } @@ -65,7 +79,29 @@ impl WebSurfaceStore { let previous_frame = self.previous_ready_frame(tab.id(), requested_url.as_str(), tab.zoom_percent()); - match self.runtime.ensure_tab(tab, size, profile_data_mode, permissions, input) { + #[cfg(test)] + let ensure_result = match native_surface { + Some(native_surface) => self.runtime.ensure_tab_with_native_surface( + tab, + size, + native_surface, + profile_data_mode, + permissions, + input, + ), + None => self.runtime.ensure_tab(tab, size, profile_data_mode, permissions, input), + }; + #[cfg(not(test))] + let ensure_result = self.runtime.ensure_tab_with_native_surface( + tab, + size, + native_surface, + profile_data_mode, + permissions, + input, + ); + + match ensure_result { Ok(result) => { self.surface_mut(tab.id()).mark_ensured(ensure_key); if result.started_loading { @@ -241,6 +277,24 @@ impl WebSurfaceStore { WebSurfaceInputOutcome::Applied } + pub(super) fn record_native_surface( + &mut self, + tab_id: &TabId, + native_surface: NativeSurfaceHandle, + ) -> WebSurfaceInputOutcome { + let surface = self.surface_mut(tab_id); + if surface + .native_surface + .as_ref() + .is_some_and(|current| current.identity() == native_surface.identity()) + { + return WebSurfaceInputOutcome::NoChange; + } + surface.native_surface = Some(native_surface); + surface.last_ensure_key = None; + WebSurfaceInputOutcome::Applied + } + pub(super) fn record_hover_point( &mut self, tab_id: &TabId, diff --git a/crates/ely_app/src/shell/web_surface_controller.rs b/crates/ely_app/src/shell/web_surface_controller.rs index 71f9c79..9a53371 100644 --- a/crates/ely_app/src/shell/web_surface_controller.rs +++ b/crates/ely_app/src/shell/web_surface_controller.rs @@ -1,6 +1,6 @@ use ely_browser_core::{BrowserCore, BrowserSnapshot}; use ely_domain::{BrowserTab, ProfileKind, TabId, UrlText}; -use gpui::{AnyElement, Bounds, Context, Pixels, Point}; +use gpui::{AnyElement, Bounds, Context, NativeSurfaceHandle, Pixels, Point}; use crate::services::ProfileDataMode; @@ -101,6 +101,25 @@ impl ElyShell { } } + pub(super) fn record_external_web_surface( + &mut self, + tab_id: TabId, + bounds: Bounds, + scale_factor: f32, + native_surface: NativeSurfaceHandle, + cx: &mut Context, + ) { + let viewport_changed = + self.web_surfaces.record_viewport_size(&tab_id, bounds, scale_factor) + == WebSurfaceInputOutcome::Applied; + let surface_changed = self.web_surfaces.record_native_surface(&tab_id, native_surface) + == WebSurfaceInputOutcome::Applied; + + if viewport_changed || surface_changed { + self.flush_external_web_surface_tick(cx); + } + } + pub(super) fn scroll_external_web_viewport( &mut self, tab_id: TabId, diff --git a/crates/ely_app/src/shell/web_surface_frame.rs b/crates/ely_app/src/shell/web_surface_frame.rs index ebe8d11..8af9a3d 100644 --- a/crates/ely_app/src/shell/web_surface_frame.rs +++ b/crates/ely_app/src/shell/web_surface_frame.rs @@ -3,10 +3,6 @@ use std::hash::Hasher; use std::sync::Arc; use ahash::AHasher; -#[cfg(target_os = "macos")] -use core_video::pixel_buffer::{CVPixelBuffer, kCVPixelFormatType_32BGRA}; -#[cfg(all(test, feature = "live-site-smoke", target_os = "macos"))] -use core_video::{pixel_buffer::kCVPixelBufferLock_ReadOnly, r#return::kCVReturnSuccess}; use gpui::RenderImage; use image::{ImageBuffer, Rgba}; use thiserror::Error; @@ -47,11 +43,7 @@ pub(super) struct WebSurfaceFrame { content_pixel_count: u64, #[cfg(all(test, feature = "live-site-smoke"))] sample_hash: u64, - /// Software-path image built from RGBA bytes. pub(super) image: Option>, - /// Hardware-path IOSurface imported from the sidecar. - #[cfg(target_os = "macos")] - pub(super) pixel_buffer: Option, } impl WebSurfaceFrame { @@ -61,8 +53,6 @@ impl WebSurfaceFrame { zoom_percent: u16, frame: ServoLiveFrame, ) -> Result { - #[cfg(target_os = "macos")] - let pixel_buffer = frame.pixel_buffer().cloned(); Self::from_parts(WebSurfaceFrameParts { requested_url, loaded_url: frame.loaded_url().map(str::to_string), @@ -84,45 +74,24 @@ impl WebSurfaceFrame { #[cfg(all(test, feature = "live-site-smoke"))] sample_hash: frame.sample_hash(), rgba_bytes: frame.into_rgba_bytes(), - #[cfg(target_os = "macos")] - pixel_buffer, }) } fn from_parts(parts: WebSurfaceFrameParts) -> Result { - #[cfg(target_os = "macos")] - let has_pixel_buffer = parts.pixel_buffer.is_some(); - #[cfg(not(target_os = "macos"))] - let has_pixel_buffer = false; - - if parts.rgba_bytes.is_empty() && !has_pixel_buffer { - return Err(WebSurfaceError::MissingRenderablePayload); - } - - #[cfg(target_os = "macos")] - if let Some(pixel_buffer) = parts.pixel_buffer.as_ref() { - validate_hardware_pixel_buffer(pixel_buffer, parts.width, parts.height)?; - } - #[cfg(all(test, feature = "live-site-smoke"))] let pixel_sample = pixel_sample_for_parts(&parts)?; - let image = if parts.rgba_bytes.is_empty() { - None - } else { - // Servo's `read_pixels(gl::RGBA, gl::UNSIGNED_BYTE)` writes - // R-G-B-A in memory order. GPUI's `RenderImage` is documented - // as "in BGRA format" and uploads via - // `MTLPixelFormat::BGRA8Unorm`, which reads B-G-R-A. Hand the bytes across - // unchanged and the Metal sampler treats R as B (and vice - // versa) — every coloured pixel renders with R and B swapped. - // Swap once here so the rest of the pipeline (dedup hash, - // image buffer, GPU upload) all operate on the same BGRA - // representation. - let mut bytes = parts.rgba_bytes; - swap_red_blue_in_place(&mut bytes); - let bytes_hash = rgba_hash(&bytes); - Some(resolve_render_image(parts.width, parts.height, bytes, bytes_hash)?) + let image = match parts.rgba_bytes { + Some(mut bytes) => { + if bytes.is_empty() { + return Err(WebSurfaceError::MissingRenderablePayload); + } + // Servo's RGBA8 readback is converted once for GPUI's BGRA upload path. + swap_red_blue_in_place(&mut bytes); + let bytes_hash = rgba_hash(&bytes); + Some(resolve_render_image(parts.width, parts.height, bytes, bytes_hash)?) + } + None => None, }; Ok(Self { @@ -146,8 +115,6 @@ impl WebSurfaceFrame { #[cfg(all(test, feature = "live-site-smoke"))] sample_hash: pixel_sample.sample_hash, image, - #[cfg(target_os = "macos")] - pixel_buffer: parts.pixel_buffer, }) } @@ -214,15 +181,13 @@ impl WebSurfaceFrame { } pub(super) fn has_same_software_render_as(&self, other: &Self) -> bool { - #[cfg(target_os = "macos")] - if self.pixel_buffer.is_some() || other.pixel_buffer.is_some() { - return false; - } - let (Some(image), Some(other_image)) = (self.image.as_ref(), other.image.as_ref()) else { - return false; + let image_matches = match (self.image.as_ref(), other.image.as_ref()) { + (Some(image), Some(other_image)) => Arc::ptr_eq(image, other_image), + (None, None) => true, + _ => false, }; - Arc::ptr_eq(image, other_image) + image_matches && self.requested_url == other.requested_url && self.loaded_url == other.loaded_url && self.title == other.title @@ -239,7 +204,6 @@ impl WebSurfaceFrame { } pub(super) fn has_visible_content_for_initial_display(&self) -> Result { - // Sidecar readback suppresses blank initial frames before publication. #[cfg(all(test, feature = "live-site-smoke"))] { Ok(self.non_white_pixel_count > 0 && self.content_pixel_count > 0) @@ -267,14 +231,7 @@ impl WebSurfaceFrame { #[cfg(all(test, feature = "live-site-smoke"))] pub(super) fn has_hardware_surface(&self) -> bool { - #[cfg(target_os = "macos")] - { - self.pixel_buffer.is_some() - } - #[cfg(not(target_os = "macos"))] - { - false - } + false } } @@ -298,67 +255,15 @@ struct WebSurfaceFrameParts { content_pixel_count: u64, #[cfg(all(test, feature = "live-site-smoke"))] sample_hash: u64, - rgba_bytes: Vec, - #[cfg(target_os = "macos")] - pixel_buffer: Option, + rgba_bytes: Option>, } #[derive(Debug, Error)] pub(super) enum WebSurfaceError { #[error("invalid servo frame buffer for {width}x{height}")] InvalidFrameBuffer { width: u32, height: u32 }, - #[error("servo live frame did not include a software image or hardware IOSurface")] + #[error("servo live frame did not include renderable pixels")] MissingRenderablePayload, - #[cfg(target_os = "macos")] - #[error( - "servo hardware surface size {actual_width}x{actual_height} did not match frame report {expected_width}x{expected_height}" - )] - HardwareSurfaceSizeMismatch { - expected_width: u32, - expected_height: u32, - actual_width: usize, - actual_height: usize, - }, - #[cfg(target_os = "macos")] - #[error("servo hardware surface pixel format 0x{actual:x} is unsupported; expected 32BGRA")] - UnsupportedHardwareSurfaceFormat { actual: u32 }, - #[cfg(all(test, feature = "live-site-smoke", target_os = "macos"))] - #[error("servo hardware surface lock failed with status {status}")] - HardwareSurfaceLockFailed { status: i32 }, - #[cfg(all(test, feature = "live-site-smoke", target_os = "macos"))] - #[error("servo hardware surface unlock failed with status {status}")] - HardwareSurfaceUnlockFailed { status: i32 }, - #[cfg(all(test, feature = "live-site-smoke", target_os = "macos"))] - #[error("servo hardware surface base address is unavailable")] - HardwareSurfaceBaseAddressUnavailable, - #[cfg(all(test, feature = "live-site-smoke", target_os = "macos"))] - #[error("servo hardware surface row stride {bytes_per_row} is too small for width {width}")] - HardwareSurfaceRowStrideTooSmall { width: usize, bytes_per_row: usize }, -} - -#[cfg(target_os = "macos")] -fn validate_hardware_pixel_buffer( - pixel_buffer: &CVPixelBuffer, - expected_width: u32, - expected_height: u32, -) -> Result<(), WebSurfaceError> { - let actual_width = pixel_buffer.get_width(); - let actual_height = pixel_buffer.get_height(); - if actual_width != expected_width as usize || actual_height != expected_height as usize { - return Err(WebSurfaceError::HardwareSurfaceSizeMismatch { - expected_width, - expected_height, - actual_width, - actual_height, - }); - } - - let actual_format = pixel_buffer.get_pixel_format(); - if actual_format != kCVPixelFormatType_32BGRA { - return Err(WebSurfaceError::UnsupportedHardwareSurfaceFormat { actual: actual_format }); - } - - Ok(()) } /// Swap byte 0 and byte 2 of every 4-byte pixel, converting Servo's @@ -387,11 +292,6 @@ struct WebSurfacePixelSample { fn pixel_sample_for_parts( parts: &WebSurfaceFrameParts, ) -> Result { - #[cfg(target_os = "macos")] - if let Some(pixel_buffer) = parts.pixel_buffer.as_ref() { - return sample_hardware_pixel_buffer(pixel_buffer); - } - Ok(WebSurfacePixelSample { non_white_pixel_count: parts.non_white_pixel_count, content_pixel_count: parts.content_pixel_count, @@ -399,82 +299,6 @@ fn pixel_sample_for_parts( }) } -#[cfg(all(test, feature = "live-site-smoke", target_os = "macos"))] -fn sample_hardware_pixel_buffer( - pixel_buffer: &CVPixelBuffer, -) -> Result { - let lock_status = pixel_buffer.lock_base_address(kCVPixelBufferLock_ReadOnly); - if lock_status != kCVReturnSuccess { - return Err(WebSurfaceError::HardwareSurfaceLockFailed { status: lock_status }); - } - - let sample = sample_locked_hardware_pixel_buffer(pixel_buffer); - let unlock_status = pixel_buffer.unlock_base_address(kCVPixelBufferLock_ReadOnly); - if unlock_status != kCVReturnSuccess { - return Err(WebSurfaceError::HardwareSurfaceUnlockFailed { status: unlock_status }); - } - - sample -} - -#[cfg(all(test, feature = "live-site-smoke", target_os = "macos"))] -fn sample_locked_hardware_pixel_buffer( - pixel_buffer: &CVPixelBuffer, -) -> Result { - let width = pixel_buffer.get_width(); - let height = pixel_buffer.get_height(); - let bytes_per_row = pixel_buffer.get_bytes_per_row(); - let row_width = width.saturating_mul(4); - if bytes_per_row < row_width { - return Err(WebSurfaceError::HardwareSurfaceRowStrideTooSmall { width, bytes_per_row }); - } - - #[expect(unsafe_code)] - let base_address = unsafe { pixel_buffer.get_base_address() }; - if base_address.is_null() { - return Err(WebSurfaceError::HardwareSurfaceBaseAddressUnavailable); - } - - let byte_len = bytes_per_row.saturating_mul(height); - #[expect(unsafe_code)] - let bytes = unsafe { std::slice::from_raw_parts(base_address.cast::(), byte_len) }; - Ok(sample_bgra_rows(bytes, width, height, bytes_per_row)) -} - -#[cfg(all(test, feature = "live-site-smoke", target_os = "macos"))] -fn sample_bgra_rows( - bytes: &[u8], - width: usize, - height: usize, - bytes_per_row: usize, -) -> WebSurfacePixelSample { - let mut non_white_pixel_count = 0; - let mut content_pixel_count = 0; - let mut sample_hash = 0xcbf29ce484222325_u64; - - for y in 0..height { - let row_start = y * bytes_per_row; - let row = &bytes[row_start..row_start + width * 4]; - for (x, pixel) in row.chunks_exact(4).enumerate() { - let [blue, green, red, alpha] = [pixel[0], pixel[1], pixel[2], pixel[3]]; - if alpha > 0 && (red < 245 || green < 245 || blue < 245) { - non_white_pixel_count += 1; - } - if alpha > 0 && (red < 220 || green < 220 || blue < 220) { - content_pixel_count += 1; - } - if (y * width + x).is_multiple_of(97) { - for byte in [red, green, blue, alpha] { - sample_hash ^= u64::from(byte); - sample_hash = sample_hash.wrapping_mul(0x100000001b3); - } - } - } - } - - WebSurfacePixelSample { non_white_pixel_count, content_pixel_count, sample_hash } -} - fn resolve_render_image( width: u32, height: u32, diff --git a/crates/ely_app/src/shell/web_surface_runtime.rs b/crates/ely_app/src/shell/web_surface_runtime.rs index abbe03d..23092ab 100644 --- a/crates/ely_app/src/shell/web_surface_runtime.rs +++ b/crates/ely_app/src/shell/web_surface_runtime.rs @@ -6,6 +6,7 @@ use std::{ }; use ely_domain::{BrowserTab, ProfileId, TabId}; +use gpui::NativeSurfaceHandle; use crate::services::{ ProfileDataMode, @@ -23,7 +24,9 @@ use super::{ }; pub(super) struct WebSurfaceRuntime { - workers: BTreeMap, + worker: Option, + direct_client: Option, + pending_direct_responses: Vec, sessions: BTreeMap, client_factory: LiveRuntimeClientFactory, } @@ -31,7 +34,9 @@ pub(super) struct WebSurfaceRuntime { impl WebSurfaceRuntime { pub(super) fn new() -> Self { Self { - workers: BTreeMap::new(), + worker: None, + direct_client: None, + pending_direct_responses: Vec::new(), sessions: BTreeMap::new(), client_factory: new_servo_live_client, } @@ -39,9 +44,16 @@ impl WebSurfaceRuntime { #[cfg(test)] pub(super) fn new_with_client_factory(client_factory: LiveRuntimeClientFactory) -> Self { - Self { workers: BTreeMap::new(), sessions: BTreeMap::new(), client_factory } + Self { + worker: None, + direct_client: None, + pending_direct_responses: Vec::new(), + sessions: BTreeMap::new(), + client_factory, + } } + #[cfg(test)] pub(super) fn ensure_tab( &mut self, tab: &BrowserTab, @@ -49,11 +61,48 @@ impl WebSurfaceRuntime { profile_data_mode: ProfileDataMode, permissions: &[WebSurfaceSitePermission], input: WebSurfacePendingInput, + ) -> Result { + self.ensure_tab_inner(tab, size, None, profile_data_mode, permissions, input) + } + + pub(super) fn ensure_tab_with_native_surface( + &mut self, + tab: &BrowserTab, + size: WebSurfaceSize, + native_surface: NativeSurfaceHandle, + profile_data_mode: ProfileDataMode, + permissions: &[WebSurfaceSitePermission], + input: WebSurfacePendingInput, + ) -> Result { + self.ensure_tab_inner( + tab, + size, + Some(native_surface), + profile_data_mode, + permissions, + input, + ) + } + + fn ensure_tab_inner( + &mut self, + tab: &BrowserTab, + size: WebSurfaceSize, + native_surface: Option, + profile_data_mode: ProfileDataMode, + permissions: &[WebSurfaceSitePermission], + input: WebSurfacePendingInput, ) -> Result { let scope = WebSurfaceRuntimeScope::new(tab.profile_id().clone(), profile_data_mode); - self.ensure_worker(scope.clone())?; + let use_direct_client = native_surface.is_some(); + if use_direct_client { + self.ensure_direct_client(scope.clone())?; + } else { + self.ensure_worker(scope.clone())?; + } let requested_url = tab.url().as_str().to_string(); + let tab_id_string = tab.id().as_str().to_string(); let zoom_percent = tab.zoom_percent(); let enqueued_at = input.enqueued_at; let input_kind = pending_input_kind(&input); @@ -88,6 +137,7 @@ impl WebSurfaceRuntime { height: size.height, page_zoom_percent: zoom_percent, device_pixel_ratio: size.device_pixel_ratio_f32(), + native_surface, scroll_delta_x, scroll_delta_y, scroll_point_x, @@ -100,10 +150,15 @@ impl WebSurfaceRuntime { site_permissions: permissions.iter().map(ServoLiveSitePermission::from).collect(), }; - let Some(scoped) = self.workers.get(&scope) else { - return Err("Servo worker was created but is no longer registered".to_string()); - }; - scoped.worker.submit_ensure(request); + if use_direct_client { + let response = self.ensure_direct(request, tab_id_string.clone())?; + self.pending_direct_responses.extend(response); + } else { + let Some(scoped) = self.worker.as_ref() else { + return Err("Servo worker was created but is no longer registered".to_string()); + }; + scoped.worker.submit_ensure(request); + } if let Some(session) = self.sessions.get_mut(tab.id()) { session.cadence.note_poll_submitted(submitted_at); } @@ -114,68 +169,19 @@ impl WebSurfaceRuntime { pub(super) fn tick(&mut self, visible_tab_ids: &[TabId]) -> Vec { let mut frames = Vec::new(); - let mut dead_scopes = Vec::new(); - let scopes: Vec = self.workers.keys().cloned().collect(); let now = Instant::now(); - for scope in scopes { - let responses = self - .workers - .get(&scope) - .map(|scoped| scoped.worker.drain_responses()) - .unwrap_or_default(); - for response in responses { - match response { - WorkerResponse::Frame { tab_id, frame } => { - let Some(tab_id_obj) = self.lookup_session_tab_id(&tab_id) else { - continue; - }; - let session = match self.sessions.get_mut(&tab_id_obj) { - Some(session) => session, - None => continue, - }; - let requested_url = session.requested_url.clone(); - let scroll_offset = session.scroll_offset; - let zoom_percent = session.zoom_percent; - session.cadence.note_frame(frame.render_state(), now); - match WebSurfaceFrame::from_live_frame( - requested_url.clone(), - scroll_offset, - zoom_percent, - frame, - ) { - Ok(frame) => { - let url_change = session.url_change_for( - &tab_id_obj, - requested_url.as_str(), - &frame, - ); - frames.push(WebSurfaceRuntimeFrame::Ready { - tab_id: tab_id_obj, - frame: Box::new(frame), - url_change, - }); - } - Err(error) => frames.push(WebSurfaceRuntimeFrame::Failed { - tab_id: tab_id_obj, - message: error.to_string(), - }), - } - } - WorkerResponse::Failed { tab_id, message } => { - let Some(tab_id_obj) = self.lookup_session_tab_id(&tab_id) else { - continue; - }; - frames.push(WebSurfaceRuntimeFrame::Failed { tab_id: tab_id_obj, message }); - } - WorkerResponse::SidecarExited => dead_scopes.push(scope.clone()), - } - } - } - for scope in dead_scopes { - self.workers.remove(&scope); + let mut responses = std::mem::take(&mut self.pending_direct_responses); + responses.extend( + self.worker.as_ref().map(|scoped| scoped.worker.drain_responses()).unwrap_or_default(), + ); + let runtime_unavailable = self.collect_responses(responses, now, &mut frames); + if runtime_unavailable { + self.remove_worker(); + self.remove_direct_client(); } let poll_now = Instant::now(); + let mut direct_polls = Vec::new(); for tab_id in visible_tab_ids { let Some(session) = self.sessions.get_mut(tab_id) else { continue; @@ -183,11 +189,22 @@ impl WebSurfaceRuntime { if !session.cadence.should_poll(poll_now) { continue; } - let Some(scoped) = self.workers.get(&session.scope) else { - continue; - }; - let _ = scoped.worker.submit_poll(tab_id.as_str().to_string()); - session.cadence.note_poll_submitted(poll_now); + if let Some(scoped) = self.worker.as_ref() { + let _ = scoped.worker.submit_poll(tab_id.as_str().to_string()); + session.cadence.note_poll_submitted(poll_now); + } else if self.direct_client.is_some() { + direct_polls.push(tab_id.as_str().to_string()); + session.cadence.note_poll_submitted(poll_now); + } + } + + if !direct_polls.is_empty() { + let (responses, runtime_unavailable) = self.poll_direct(direct_polls); + let unavailable_from_responses = + self.collect_responses(responses, Instant::now(), &mut frames); + if runtime_unavailable || unavailable_from_responses { + self.remove_direct_client(); + } } frames @@ -201,38 +218,156 @@ impl WebSurfaceRuntime { visible_tab_ids .iter() .filter_map(|tab_id| self.sessions.get(tab_id)) - .filter(|session| self.workers.contains_key(&session.scope)) + .filter(|_| self.worker.is_some() || self.direct_client.is_some()) .map(|session| session.cadence.next_poll_delay(now)) .min() } pub(super) fn close_tab(&mut self, tab_id: &TabId) { - let Some(session) = self.sessions.remove(tab_id) else { + if self.sessions.remove(tab_id).is_none() { return; - }; - if let Some(scoped) = self.workers.get(&session.scope) { + } + let direct_result = self + .direct_client + .as_mut() + .map(|scoped| scoped.client.close(tab_id.as_str().to_string())); + if direct_result.as_ref().is_some_and(|result| { + result.as_ref().is_err_and(|error| error.is_runtime_unavailable()) + }) { + self.remove_direct_client(); + } + if let Some(scoped) = self.worker.as_ref() { scoped.worker.submit_close(tab_id.as_str().to_string()); } } fn ensure_worker(&mut self, scope: WebSurfaceRuntimeScope) -> Result<(), String> { - if self.workers.contains_key(&scope) { + if self.worker.is_some() { + return Ok(()); + } + let (config_dir, transient_profile_data_dir) = config_dir_for_scope(&scope)?; + let client_factory = self.client_factory; + let worker = LiveRuntimeWorker::new(move || client_factory(config_dir))?; + self.worker = Some(ScopedWorker { worker, transient_profile_data_dir }); + Ok(()) + } + + fn ensure_direct_client(&mut self, scope: WebSurfaceRuntimeScope) -> Result<(), String> { + if self.direct_client.is_some() { return Ok(()); } let (config_dir, transient_profile_data_dir) = config_dir_for_scope(&scope)?; let client = (self.client_factory)(config_dir)?; - let worker = LiveRuntimeWorker::new(client)?; - self.workers.insert(scope, ScopedWorker { worker, transient_profile_data_dir }); + self.direct_client = Some(ScopedDirectClient { client, transient_profile_data_dir }); Ok(()) } + fn ensure_direct( + &mut self, + request: ServoLiveEnsureRequest, + tab_id: String, + ) -> Result, String> { + let Some(scoped) = self.direct_client.as_mut() else { + return Err("Servo client was created but is no longer registered".to_string()); + }; + match scoped.client.ensure(request) { + Ok(Some(frame)) => Ok(Some(WorkerResponse::Frame { tab_id, frame })), + Ok(None) => Ok(None), + Err(error) => { + let message = error.to_string(); + if error.is_runtime_unavailable() { + self.remove_direct_client(); + } + Err(message) + } + } + } + + fn poll_direct(&mut self, tab_ids: Vec) -> (Vec, bool) { + let Some(scoped) = self.direct_client.as_mut() else { + return (Vec::new(), false); + }; + let mut responses = Vec::new(); + let mut runtime_unavailable = false; + for tab_id in tab_ids { + match scoped.client.poll(tab_id.clone()) { + Ok(Some(frame)) => responses.push(WorkerResponse::Frame { tab_id, frame }), + Ok(None) => {} + Err(error) if error.is_runtime_unavailable() => { + runtime_unavailable = true; + responses.push(WorkerResponse::RuntimeUnavailable); + } + Err(error) => { + responses.push(WorkerResponse::Failed { tab_id, message: error.to_string() }) + } + } + } + (responses, runtime_unavailable) + } + + fn collect_responses( + &mut self, + responses: Vec, + now: Instant, + frames: &mut Vec, + ) -> bool { + let mut runtime_unavailable = false; + for response in responses { + match response { + WorkerResponse::Frame { tab_id, frame } => { + let Some(tab_id_obj) = self.lookup_session_tab_id(&tab_id) else { + continue; + }; + let session = match self.sessions.get_mut(&tab_id_obj) { + Some(session) => session, + None => continue, + }; + let requested_url = session.requested_url.clone(); + let scroll_offset = session.scroll_offset; + let zoom_percent = session.zoom_percent; + session.cadence.note_frame(frame.render_state(), now); + match WebSurfaceFrame::from_live_frame( + requested_url.clone(), + scroll_offset, + zoom_percent, + frame, + ) { + Ok(frame) => { + let url_change = + session.url_change_for(&tab_id_obj, requested_url.as_str(), &frame); + frames.push(WebSurfaceRuntimeFrame::Ready { + tab_id: tab_id_obj, + frame: Box::new(frame), + url_change, + }); + } + Err(error) => frames.push(WebSurfaceRuntimeFrame::Failed { + tab_id: tab_id_obj, + message: error.to_string(), + }), + } + } + WorkerResponse::Failed { tab_id, message } => { + let Some(tab_id_obj) = self.lookup_session_tab_id(&tab_id) else { + continue; + }; + frames.push(WebSurfaceRuntimeFrame::Failed { tab_id: tab_id_obj, message }); + } + WorkerResponse::RuntimeUnavailable => { + runtime_unavailable = true; + } + } + } + runtime_unavailable + } + fn lookup_session_tab_id(&self, tab_id: &str) -> Option { self.sessions.keys().find(|key| key.as_str() == tab_id).cloned() } #[cfg(test)] pub(super) fn client_count_for_test(&self) -> usize { - self.workers.len() + usize::from(self.worker.is_some()) + usize::from(self.direct_client.is_some()) } #[cfg(test)] @@ -242,23 +377,34 @@ impl WebSurfaceRuntime { #[cfg(test)] pub(super) fn flush_for_test(&self) { - for scoped in self.workers.values() { + if let Some(scoped) = self.worker.as_ref() { scoped.worker.wait_until_idle(); } } + + fn remove_worker(&mut self) { + let Some(scoped) = self.worker.take() else { + return; + }; + if let Some(path) = scoped.transient_profile_data_dir { + let _ = fs::remove_dir_all(path); + } + } + + fn remove_direct_client(&mut self) { + let Some(scoped) = self.direct_client.take() else { + return; + }; + if let Some(path) = scoped.transient_profile_data_dir { + let _ = fs::remove_dir_all(path); + } + } } impl Drop for WebSurfaceRuntime { fn drop(&mut self) { - let transient_profile_data_dirs = self - .workers - .values() - .filter_map(|scoped| scoped.transient_profile_data_dir.clone()) - .collect::>(); - self.workers.clear(); - for path in transient_profile_data_dirs { - let _ = fs::remove_dir_all(path); - } + self.remove_worker(); + self.remove_direct_client(); } } @@ -276,6 +422,11 @@ struct ScopedWorker { transient_profile_data_dir: Option, } +struct ScopedDirectClient { + client: Box, + transient_profile_data_dir: Option, +} + #[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] pub(super) struct WebSurfaceRuntimeScope { profile_id: ProfileId, diff --git a/crates/ely_app/src/shell/web_surface_runtime_tests.rs b/crates/ely_app/src/shell/web_surface_runtime_tests.rs index eaad13b..b42b981 100644 --- a/crates/ely_app/src/shell/web_surface_runtime_tests.rs +++ b/crates/ely_app/src/shell/web_surface_runtime_tests.rs @@ -29,7 +29,7 @@ static FAILING_ENSURE_COUNT: AtomicUsize = AtomicUsize::new(0); static REPEATED_FRAME_ENSURE_COUNT: AtomicUsize = AtomicUsize::new(0); #[test] -fn runtime_keeps_independent_clients_for_profile_scopes() -> Result<(), String> { +fn runtime_shares_direct_servo_client_across_profile_scopes() -> Result<(), String> { let mut runtime = WebSurfaceRuntime::new_with_client_factory(fake_client_factory); let first_profile = ProfileId::new(); let second_profile = ProfileId::new(); @@ -60,7 +60,7 @@ fn runtime_keeps_independent_clients_for_profile_scopes() -> Result<(), String> runtime.flush_for_test(); - assert_eq!(runtime.client_count_for_test(), 2); + assert_eq!(runtime.client_count_for_test(), 1); assert_eq!( runtime.session_scope_for_test(first_tab.id()), Some(&WebSurfaceRuntimeScope::new(first_profile, ProfileDataMode::Transient)), @@ -183,7 +183,7 @@ fn identical_ready_software_frame_keeps_tick_unchanged() -> Result<(), String> { } #[test] -fn sidecar_exit_removes_dead_runtime_client() -> Result<(), String> { +fn runtime_unavailable_removes_dead_runtime_client() -> Result<(), String> { RECOVERY_FACTORY_COUNT.store(0, Ordering::SeqCst); let mut runtime = WebSurfaceRuntime::new_with_client_factory(recovery_client_factory); let profile = ProfileId::new(); @@ -281,7 +281,7 @@ fn session_scope_change_resets_tab_state() { struct FakeLiveRuntimeClient; struct IdleSkipLiveRuntimeClient; -struct SidecarExitLiveRuntimeClient; +struct RuntimeUnavailableLiveRuntimeClient; struct FailingLiveRuntimeClient; struct RepeatedFrameLiveRuntimeClient; @@ -322,20 +322,20 @@ impl LiveRuntimeClient for IdleSkipLiveRuntimeClient { } } -impl LiveRuntimeClient for SidecarExitLiveRuntimeClient { +impl LiveRuntimeClient for RuntimeUnavailableLiveRuntimeClient { fn ensure( &mut self, _request: ServoLiveEnsureRequest, ) -> Result, LiveRuntimeClientError> { - Err(LiveRuntimeClientError::SidecarExited) + Err(LiveRuntimeClientError::RuntimeUnavailable) } fn poll(&mut self, _tab_id: String) -> Result, LiveRuntimeClientError> { - Err(LiveRuntimeClientError::SidecarExited) + Err(LiveRuntimeClientError::RuntimeUnavailable) } fn close(&mut self, _tab_id: String) -> Result<(), LiveRuntimeClientError> { - Err(LiveRuntimeClientError::SidecarExited) + Err(LiveRuntimeClientError::RuntimeUnavailable) } } @@ -345,7 +345,7 @@ impl LiveRuntimeClient for FailingLiveRuntimeClient { _request: ServoLiveEnsureRequest, ) -> Result, LiveRuntimeClientError> { FAILING_ENSURE_COUNT.fetch_add(1, Ordering::SeqCst); - Err(LiveRuntimeClientError::SidecarExited) + Err(LiveRuntimeClientError::RuntimeUnavailable) } fn poll(&mut self, _tab_id: String) -> Result, LiveRuntimeClientError> { @@ -392,7 +392,7 @@ fn recovery_client_factory( ) -> Result, String> { let factory_call = RECOVERY_FACTORY_COUNT.fetch_add(1, Ordering::SeqCst); if factory_call == 0 { - return Ok(Box::new(SidecarExitLiveRuntimeClient)); + return Ok(Box::new(RuntimeUnavailableLiveRuntimeClient)); } Ok(Box::new(FakeLiveRuntimeClient)) } diff --git a/crates/ely_app/src/shell/web_surface_state.rs b/crates/ely_app/src/shell/web_surface_state.rs index 4f68ccc..f3fc3ed 100644 --- a/crates/ely_app/src/shell/web_surface_state.rs +++ b/crates/ely_app/src/shell/web_surface_state.rs @@ -1,7 +1,7 @@ use std::time::{Duration, Instant}; use ely_domain::TabId; -use gpui::{Bounds, Pixels}; +use gpui::{Bounds, NativeSurfaceHandle, Pixels}; use super::{ web_surface_cadence::ACTIVE_POLL_INTERVAL, @@ -126,6 +126,7 @@ pub(super) struct WebSurfaceTickResult { pub(super) struct PerTabSurface { pub(super) viewport_bounds: Option>, pub(super) viewport_size: Option, + pub(super) native_surface: Option, pub(super) last_ensure_key: Option, pub(super) hover_point: Option, last_hover_enqueued_at: Option, @@ -145,6 +146,7 @@ impl PerTabSurface { Self { viewport_bounds: None, viewport_size: None, + native_surface: None, last_ensure_key: None, hover_point: None, last_hover_enqueued_at: None, @@ -228,6 +230,7 @@ const HOVER_INPUT_MIN_INTERVAL: Duration = Duration::from_millis(32); pub(super) struct WebSurfaceEnsureKey { requested_url: String, size: WebSurfaceSize, + native_surface_id: Option, zoom_percent: u16, permissions: Vec, } @@ -236,10 +239,17 @@ impl WebSurfaceEnsureKey { pub(super) fn new( requested_url: String, size: WebSurfaceSize, + native_surface: Option<&NativeSurfaceHandle>, zoom_percent: u16, permissions: &[WebSurfaceSitePermission], ) -> Self { - Self { requested_url, size, zoom_percent, permissions: permissions.to_vec() } + Self { + requested_url, + size, + native_surface_id: native_surface.map(NativeSurfaceHandle::identity), + zoom_percent, + permissions: permissions.to_vec(), + } } } @@ -297,6 +307,7 @@ mod tests { WebSurfaceEnsureKey::new( url.to_string(), WebSurfaceSize { width, height, device_pixel_ratio_percent: 100 }, + None, 100, &[], ) diff --git a/crates/ely_app/src/shell/web_surface_tests.rs b/crates/ely_app/src/shell/web_surface_tests.rs index 698fc88..f6b7ebf 100644 --- a/crates/ely_app/src/shell/web_surface_tests.rs +++ b/crates/ely_app/src/shell/web_surface_tests.rs @@ -108,7 +108,7 @@ fn scroll_after_click_keeps_keyboard_focus_and_typed_text() -> Result<(), Box Result<(), Box Result<(), String> { Ok(_) => return Err("empty Servo frame payload reached Ready state".to_string()), Err(error) => error, }; - assert_eq!( - error.to_string(), - "servo live frame did not include a software image or hardware IOSurface", - ); - Ok(()) -} - -#[cfg(target_os = "macos")] -#[test] -fn hardware_live_frame_with_pixel_buffer_skips_software_image() -> Result<(), String> { - use core_video::pixel_buffer::{CVPixelBuffer, kCVPixelFormatType_32BGRA}; - - use crate::services::servo_live::ServoLiveFrame; - use crate::shell::web_surface_frame::WebSurfaceFrame; - use crate::shell::web_surface_geometry::WebSurfaceScrollOffset; - - let pixel_buffer = CVPixelBuffer::new(kCVPixelFormatType_32BGRA, 1, 1, None) - .map_err(|status| format!("CVPixelBufferCreate returned status {status}"))?; - let live = ServoLiveFrame::for_test_with_pixel_buffer(1, 1, pixel_buffer); - let frame = WebSurfaceFrame::from_live_frame( - "https://example.com/".to_string(), - WebSurfaceScrollOffset::default(), - 100, - live, - ) - .map_err(|error| error.to_string())?; - - assert!(frame.image.is_none(), "hardware frame should use the CVPixelBuffer surface path"); - assert!(frame.pixel_buffer.is_some(), "hardware frame should carry the imported CVPixelBuffer"); - Ok(()) -} - -#[cfg(target_os = "macos")] -#[test] -fn hardware_live_frame_rejects_mismatched_surface_size() -> Result<(), String> { - use core_video::pixel_buffer::{CVPixelBuffer, kCVPixelFormatType_32BGRA}; - - use crate::services::servo_live::ServoLiveFrame; - use crate::shell::web_surface_frame::WebSurfaceFrame; - use crate::shell::web_surface_geometry::WebSurfaceScrollOffset; - - let pixel_buffer = CVPixelBuffer::new(kCVPixelFormatType_32BGRA, 2, 1, None) - .map_err(|status| format!("CVPixelBufferCreate returned status {status}"))?; - let live = ServoLiveFrame::for_test_with_pixel_buffer(1, 1, pixel_buffer); - let result = WebSurfaceFrame::from_live_frame( - "https://example.com/".to_string(), - WebSurfaceScrollOffset::default(), - 100, - live, - ); - - let error = match result { - Ok(_) => return Err("mismatched hardware surface reached Ready state".to_string()), - Err(error) => error, - }; - assert_eq!(error.to_string(), "servo hardware surface size 2x1 did not match frame report 1x1",); - Ok(()) -} - -#[cfg(target_os = "macos")] -#[test] -fn hardware_live_frame_rejects_unsupported_surface_format() -> Result<(), String> { - use core_video::pixel_buffer::{CVPixelBuffer, kCVPixelFormatType_420YpCbCr8BiPlanarFullRange}; - - use crate::services::servo_live::ServoLiveFrame; - use crate::shell::web_surface_frame::WebSurfaceFrame; - use crate::shell::web_surface_geometry::WebSurfaceScrollOffset; - - let pixel_buffer = - CVPixelBuffer::new(kCVPixelFormatType_420YpCbCr8BiPlanarFullRange, 2, 2, None) - .map_err(|status| format!("CVPixelBufferCreate returned status {status}"))?; - let live = ServoLiveFrame::for_test_with_pixel_buffer(2, 2, pixel_buffer); - let result = WebSurfaceFrame::from_live_frame( - "https://example.com/".to_string(), - WebSurfaceScrollOffset::default(), - 100, - live, - ); - - let error = match result { - Ok(_) => return Err("unsupported hardware surface format reached Ready state".to_string()), - Err(error) => error, - }; - assert_eq!( - error.to_string(), - "servo hardware surface pixel format 0x34323066 is unsupported; expected 32BGRA", - ); - Ok(()) -} - -#[cfg(all(target_os = "macos", feature = "live-site-smoke"))] -#[test] -fn hardware_live_frame_samples_bgra_surface_pixels() -> Result<(), String> { - use core_video::{ - pixel_buffer::{CVPixelBuffer, kCVPixelFormatType_32BGRA}, - r#return::kCVReturnSuccess, - }; - - use crate::services::servo_live::ServoLiveFrame; - use crate::shell::web_surface_frame::WebSurfaceFrame; - use crate::shell::web_surface_geometry::WebSurfaceScrollOffset; - - let pixel_buffer = CVPixelBuffer::new(kCVPixelFormatType_32BGRA, 2, 1, None) - .map_err(|status| format!("CVPixelBufferCreate returned status {status}"))?; - let lock_status = pixel_buffer.lock_base_address(0); - if lock_status != kCVReturnSuccess { - return Err(format!("CVPixelBufferLockBaseAddress returned status {lock_status}")); - } - let bytes_per_row = pixel_buffer.get_bytes_per_row(); - #[expect(unsafe_code)] - unsafe { - let base_address = pixel_buffer.get_base_address().cast::(); - let bytes = std::slice::from_raw_parts_mut(base_address, bytes_per_row); - bytes[0..8].copy_from_slice(&[ - 0, 0, 255, 255, // red in BGRA memory order - 255, 255, 255, 255, - ]); - } - let unlock_status = pixel_buffer.unlock_base_address(0); - if unlock_status != kCVReturnSuccess { - return Err(format!("CVPixelBufferUnlockBaseAddress returned status {unlock_status}")); - } - - let live = ServoLiveFrame::for_test_with_pixel_buffer(2, 1, pixel_buffer); - let frame = WebSurfaceFrame::from_live_frame( - "https://example.com/".to_string(), - WebSurfaceScrollOffset::default(), - 100, - live, - ) - .map_err(|error| error.to_string())?; - - assert_eq!(frame.non_white_pixel_count(), 1); - assert_eq!(frame.content_pixel_count(), 1); - assert_ne!(frame.sample_hash(), 0); + assert_eq!(error.to_string(), "servo live frame did not include renderable pixels",); Ok(()) } diff --git a/crates/ely_app/src/shell/web_surface_view.rs b/crates/ely_app/src/shell/web_surface_view.rs index 947595e..0c91e18 100644 --- a/crates/ely_app/src/shell/web_surface_view.rs +++ b/crates/ely_app/src/shell/web_surface_view.rs @@ -1,51 +1,28 @@ use ely_domain::{BrowserTab, TabId}; use gpui::{ - AnyElement, App, Corners, Entity, ImageSource, InteractiveElement, IntoElement, MouseButton, - ObjectFit, ParentElement, Styled, StyledImage, Window, canvas, div, img, px, rgb, surface, + AnyElement, App, ElementId, Entity, InteractiveElement, IntoElement, MouseButton, + ParentElement, Styled, Window, canvas, div, native_surface, px, rgb, }; use super::{ ElyShell, web_surface_frame::WebSurfaceFrame, web_surface_geometry::servo_scroll_delta_from_wheel_delta, }; -use ely_design_system::{colors, spacing}; +use ely_design_system::colors; pub(super) fn render_ready_web_surface( - frame: &WebSurfaceFrame, + _frame: &WebSurfaceFrame, tab: &BrowserTab, state_entity: Entity, ) -> AnyElement { - #[cfg(target_os = "macos")] - if let Some(pixel_buffer) = frame.pixel_buffer.as_ref() { - return render_web_surface( - tab, - state_entity, - surface(pixel_buffer.clone()) - .size_full() - .corner_radii(web_surface_corner_radii()) - .object_fit(ObjectFit::Fill), - ); - } - - if let Some(image) = frame.image.as_ref() { - return render_web_surface( - tab, - state_entity, - img(ImageSource::Render(image.clone())).size_full().object_fit(ObjectFit::Fill), - ); - } - render_web_surface( - tab, - state_entity, - error_page("Web surface frame did not include renderable pixels."), - ) + render_web_surface(tab, state_entity.clone(), render_native_web_surface(tab, state_entity)) } pub(super) fn render_loading_web_surface( tab: &BrowserTab, state_entity: Entity, ) -> AnyElement { - render_web_surface(tab, state_entity, div().size_full()) + render_web_surface(tab, state_entity.clone(), render_native_web_surface(tab, state_entity)) } pub(super) fn render_failed_web_surface( @@ -56,15 +33,6 @@ pub(super) fn render_failed_web_surface( render_web_surface(tab, state_entity, error_page(message)) } -fn web_surface_corner_radii() -> Corners { - Corners { - top_left: px(0.0), - top_right: px(0.0), - bottom_right: px(spacing::RADIUS_CARD), - bottom_left: px(spacing::RADIUS_CARD), - } -} - fn error_page(message: &str) -> impl IntoElement { div() .size_full() @@ -107,6 +75,18 @@ fn render_web_surface( .into_any_element() } +fn render_native_web_surface(tab: &BrowserTab, state_entity: Entity) -> impl IntoElement { + let tab_id = tab.id().clone(); + let element_id = ElementId::Name(format!("web-surface-{}", tab_id.as_str()).into()); + native_surface(element_id, move |surface, bounds, window: &mut Window, cx: &mut App| { + let scale_factor = window.scale_factor(); + state_entity.update(cx, |shell, cx| { + shell.record_external_web_surface(tab_id.clone(), bounds, scale_factor, surface, cx); + }); + }) + .size_full() +} + fn render_input_overlay( tab_id: TabId, url: String, diff --git a/crates/ely_app/src/shell/web_surface_worker.rs b/crates/ely_app/src/shell/web_surface_worker.rs index e66ef6c..26fa900 100644 --- a/crates/ely_app/src/shell/web_surface_worker.rs +++ b/crates/ely_app/src/shell/web_surface_worker.rs @@ -9,13 +9,12 @@ use crate::services::servo_live::{ ServoLiveClient, ServoLiveEnsureRequest, ServoLiveError, ServoLiveFrame, }; -/// IPC surface for the per-profile Servo sidecar. +/// Blocking surface for the embedded Servo runtime. /// /// Production wraps [`ServoLiveClient`] directly; tests substitute a /// fake. The contract: every call is blocking and may run for tens of -/// milliseconds. Implementations live on the worker thread, never the -/// UI thread. -pub(super) trait LiveRuntimeClient: Send { +/// milliseconds. Implementations live on the worker thread. +pub(super) trait LiveRuntimeClient { fn ensure( &mut self, request: ServoLiveEnsureRequest, @@ -45,20 +44,20 @@ impl LiveRuntimeClient for ServoLiveClient { #[derive(Debug)] pub(super) enum LiveRuntimeClientError { - SidecarExited, + RuntimeUnavailable, Message(String), } impl LiveRuntimeClientError { - pub(super) fn is_sidecar_exited(&self) -> bool { - matches!(self, Self::SidecarExited) + pub(super) fn is_runtime_unavailable(&self) -> bool { + matches!(self, Self::RuntimeUnavailable) } } impl std::fmt::Display for LiveRuntimeClientError { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - Self::SidecarExited => formatter.write_str("servo live sidecar exited"), + Self::RuntimeUnavailable => formatter.write_str("servo live runtime is unavailable"), Self::Message(message) => formatter.write_str(message), } } @@ -72,8 +71,8 @@ impl From for LiveRuntimeClientError { impl From for LiveRuntimeClientError { fn from(error: ServoLiveError) -> Self { - if error.is_sidecar_process_unusable() { - return Self::SidecarExited; + if error.is_runtime_unavailable() { + return Self::RuntimeUnavailable; } Self::Message(error.to_string()) } @@ -89,7 +88,7 @@ impl From for LiveRuntimeClientError { pub(super) enum WorkerResponse { Frame { tab_id: String, frame: ServoLiveFrame }, Failed { tab_id: String, message: String }, - SidecarExited, + RuntimeUnavailable, } enum WorkerRequest { @@ -116,9 +115,9 @@ struct WorkerQueue { /// Owns a [`LiveRuntimeClient`] on a dedicated OS thread and exposes /// a non-blocking API: submit ensure/poll/close, then drain responses. /// -/// The UI thread never blocks on Servo IPC. Submissions push into a +/// The UI thread never blocks on Servo. Submissions push into a /// coalescing queue (latest request per tab wins). The worker thread -/// drains the queue, runs the blocking IPC, and emits responses on a +/// drains the queue, runs the blocking calls, and emits responses on a /// `std::sync::mpsc` channel that the UI thread reads with `try_recv`. pub(super) struct LiveRuntimeWorker { queue: Arc<(Mutex, Condvar)>, @@ -127,7 +126,9 @@ pub(super) struct LiveRuntimeWorker { } impl LiveRuntimeWorker { - pub(super) fn new(client: Box) -> Result { + pub(super) fn new( + client_factory: impl FnOnce() -> Result, String> + Send + 'static, + ) -> Result { let queue = Arc::new(( Mutex::new(WorkerQueue { pending: BTreeMap::new(), @@ -138,13 +139,35 @@ impl LiveRuntimeWorker { Condvar::new(), )); let (response_tx, response_rx) = mpsc::channel(); + let (init_tx, init_rx) = mpsc::channel(); let queue_for_thread = queue.clone(); let thread = std::thread::Builder::new() - .name("ely-servo-live".to_string()) + .name("ely-servo-runtime".to_string()) .spawn(move || { + let client = match client_factory() { + Ok(client) => { + let _ = init_tx.send(Ok(())); + client + } + Err(error) => { + let _ = init_tx.send(Err(error)); + return; + } + }; run_worker(client, queue_for_thread, response_tx); }) .map_err(|error| format!("failed to spawn servo live worker thread: {error}"))?; + match init_rx.recv() { + Ok(Ok(())) => {} + Ok(Err(error)) => { + let _ = thread.join(); + return Err(error); + } + Err(error) => { + let _ = thread.join(); + return Err(format!("servo live worker initialization failed: {error}")); + } + } Ok(Self { queue, response_rx, thread: Some(thread) }) } @@ -317,7 +340,7 @@ enum Work { } /// Forward a single client result to the response channel. Returns -/// `true` when the worker should exit (sidecar process died). +/// `true` when the worker should exit. fn dispatch_result( response_tx: &mpsc::Sender, tab_id: String, @@ -330,11 +353,11 @@ fn dispatch_result( } Ok(None) => false, Err(error) => { - let exited = error.is_sidecar_exited(); + let unavailable = error.is_runtime_unavailable(); let message = error.to_string(); let _ = response_tx.send(WorkerResponse::Failed { tab_id, message }); - if exited { - let _ = response_tx.send(WorkerResponse::SidecarExited); + if unavailable { + let _ = response_tx.send(WorkerResponse::RuntimeUnavailable); return true; } false diff --git a/crates/ely_servo_host/Cargo.toml b/crates/ely_servo_host/Cargo.toml index aaff3ab..91533fd 100644 --- a/crates/ely_servo_host/Cargo.toml +++ b/crates/ely_servo_host/Cargo.toml @@ -7,41 +7,30 @@ rust-version.workspace = true [features] default = [] -servo-engine = ["dep:dpi", "dep:euclid", "dep:serde", "dep:serde_json", "dep:servo", "dep:url"] -hardware-render = [ - "servo-engine", - "dep:gleam", - "dep:glow", - "dep:image", - "dep:log", - "dep:mach2", - "dep:surfman", - "dep:objc2-io-surface", +servo-engine = [ + "dep:dpi", + "dep:euclid", + "dep:naga", + "dep:raw-window-handle", + "dep:rustls", + "dep:serde", + "dep:serde_json", + "dep:servo", + "dep:url", ] -[[bin]] -name = "ely_servo_sidecar" -path = "src/bin/ely_servo_sidecar.rs" -required-features = ["servo-engine"] - [dependencies] dpi = { workspace = true, optional = true } ely_domain = { path = "../ely_domain" } euclid = { version = "0.22", optional = true } -gleam = { version = "0.15", optional = true } -glow = { version = "0.16", optional = true } -image = { workspace = true, optional = true } -log = { version = "0.4", optional = true } +naga = { version = "26.0.0", features = ["termcolor"], optional = true } +raw-window-handle = { version = "0.6", optional = true } +rustls = { version = "0.23.40", default-features = false, features = ["std", "aws_lc_rs"], optional = true } serde = { workspace = true, optional = true } serde_json = { workspace = true, optional = true } servo = { workspace = true, optional = true } -surfman = { version = "0.11", optional = true } thiserror.workspace = true url = { workspace = true, optional = true } -[target.'cfg(target_os = "macos")'.dependencies] -mach2 = { version = "0.6", optional = true } -objc2-io-surface = { version = "0.3.2", optional = true } - [lints] workspace = true diff --git a/crates/ely_servo_host/src/bin/ely_servo_sidecar.rs b/crates/ely_servo_host/src/bin/ely_servo_sidecar.rs deleted file mode 100644 index 9916b63..0000000 --- a/crates/ely_servo_host/src/bin/ely_servo_sidecar.rs +++ /dev/null @@ -1,332 +0,0 @@ -use std::{ - io::Write, - thread, - time::{Duration, Instant}, -}; - -use ely_domain::TabId; -use ely_servo_host::{ - KeyboardTextRequest, MouseClickRequest, MouseDragRequest, NavigationRequest, PageZoomRequest, - PermissionRequest, ScrollRequest, ServoHost, ServoHostError, ServoSurfaceSize, - SoftwareServoHost, TouchTapRequest, WebViewSnapshot, WebViewState, -}; -use thiserror::Error; - -#[path = "ely_servo_sidecar/args.rs"] -mod args; -#[cfg(all(feature = "hardware-render", target_os = "macos"))] -#[path = "ely_servo_sidecar/iosurface_mach.rs"] -mod iosurface_mach; -#[path = "ely_servo_sidecar/live.rs"] -mod live; -#[path = "ely_servo_sidecar/live_output.rs"] -mod live_output; -#[path = "ely_servo_sidecar/live_protocol.rs"] -mod live_protocol; -#[path = "ely_servo_sidecar/live_session.rs"] -mod live_session; -#[path = "ely_servo_sidecar/perf.rs"] -mod perf; -#[path = "ely_servo_sidecar/report.rs"] -mod report; - -use args::{SidecarCommand, SnapshotArgs}; -use report::{SnapshotInputChanges, SnapshotReport}; - -const WAIT_ITERATIONS: usize = 5_000; -const WAIT_INTERVAL: Duration = Duration::from_millis(2); -const RENDER_TIMEOUT: Duration = Duration::from_secs(20); -const VISIBLE_FRAME_SETTLE_TIMEOUT: Duration = Duration::from_millis(700); -const INPUT_SETTLE_TIMEOUT: Duration = Duration::from_millis(700); - -fn main() -> Result<(), SidecarError> { - match args::parse_env_command()? { - SidecarCommand::Live(args) => live::run_live(args).map_err(SidecarError::Live), - SidecarCommand::Snapshot(args) => run_snapshot(args), - } -} - -#[derive(Debug, Error)] -enum SidecarError { - #[error("timed out rendering {url}: {snapshot:?}")] - RenderTimeout { url: String, snapshot: Box }, - - #[error(transparent)] - Args(#[from] args::SidecarArgsError), - - #[error(transparent)] - Host(#[from] ServoHostError), - - #[error(transparent)] - Live(#[from] live::LiveSidecarError), - - #[error(transparent)] - Io(#[from] std::io::Error), - - #[error(transparent)] - Json(#[from] serde_json::Error), -} - -fn run_snapshot(args: SnapshotArgs) -> Result<(), SidecarError> { - std::fs::create_dir_all(&args.profile_data_dir)?; - let mut host = SoftwareServoHost::new_with_config_dir( - ServoSurfaceSize::new(args.width, args.height), - Some(args.profile_data_dir.clone()), - )?; - let tab_id = TabId::new(); - let webview_id = host.create_webview(tab_id.clone(), args.profile_id.clone())?; - apply_site_permissions(&mut host, &webview_id, &args)?; - host.set_page_zoom(PageZoomRequest { - webview_id: webview_id.clone(), - zoom_factor: f32::from(args.page_zoom_percent) / 100.0, - })?; - - host.navigate(NavigationRequest { - webview_id: webview_id.clone(), - tab_id, - url: args.url.clone(), - })?; - - let snapshot = wait_for_frame(&mut host, &webview_id, args.url.as_str())?; - let (snapshot, scroll_changed_frame) = - apply_scroll_if_requested(&mut host, &webview_id, &args, snapshot)?; - let (snapshot, click_changed_frame) = - apply_click_if_requested(&mut host, &webview_id, &args, snapshot)?; - let (snapshot, drag_changed_frame) = - apply_drag_if_requested(&mut host, &webview_id, &args, snapshot)?; - let (snapshot, touch_changed_frame) = - apply_touch_if_requested(&mut host, &webview_id, &args, snapshot)?; - let (snapshot, text_changed_frame) = - apply_text_if_requested(&mut host, &webview_id, &args, snapshot)?; - let frame = host.last_rendered_frame()?; - std::fs::write(&args.rgba_out, frame.rgba_bytes())?; - - let mut stdout = std::io::stdout().lock(); - serde_json::to_writer( - &mut stdout, - &SnapshotReport::new( - &args, - &snapshot, - &frame, - SnapshotInputChanges { - scroll: scroll_changed_frame, - click: click_changed_frame, - drag: drag_changed_frame, - touch: touch_changed_frame, - text: text_changed_frame, - }, - ), - )?; - stdout.write_all(b"\n")?; - stdout.flush()?; - std::process::exit(0); -} - -fn apply_site_permissions( - host: &mut SoftwareServoHost, - webview_id: &ely_domain::WebViewId, - args: &SnapshotArgs, -) -> Result<(), SidecarError> { - for permission in &args.site_permissions { - host.set_permission( - PermissionRequest { - webview_id: webview_id.clone(), - profile_id: args.profile_id.clone(), - origin: permission.origin.clone(), - feature: permission.feature, - }, - permission.decision.into(), - )?; - } - - Ok(()) -} - -fn apply_scroll_if_requested( - host: &mut SoftwareServoHost, - webview_id: &ely_domain::WebViewId, - args: &SnapshotArgs, - snapshot: WebViewSnapshot, -) -> Result<(WebViewSnapshot, bool), SidecarError> { - if args.scroll_x == 0 && args.scroll_y == 0 { - return Ok((snapshot, false)); - } - - let previous_frame_hash = host.last_rendered_frame()?.sample_hash(); - host.scroll(ScrollRequest { - webview_id: webview_id.clone(), - delta_x: args.scroll_x, - delta_y: args.scroll_y, - point_x: 0, - point_y: 0, - })?; - wait_for_changed_or_settled_frame(host, webview_id, previous_frame_hash) -} - -fn apply_click_if_requested( - host: &mut SoftwareServoHost, - webview_id: &ely_domain::WebViewId, - args: &SnapshotArgs, - snapshot: WebViewSnapshot, -) -> Result<(WebViewSnapshot, bool), SidecarError> { - let Some(click_point) = args.click_point else { - return Ok((snapshot, false)); - }; - - let previous_frame_hash = host.last_rendered_frame()?.sample_hash(); - host.click(MouseClickRequest { - webview_id: webview_id.clone(), - x: click_point.x, - y: click_point.y, - })?; - wait_for_changed_or_settled_frame(host, webview_id, previous_frame_hash) -} - -fn apply_drag_if_requested( - host: &mut SoftwareServoHost, - webview_id: &ely_domain::WebViewId, - args: &SnapshotArgs, - snapshot: WebViewSnapshot, -) -> Result<(WebViewSnapshot, bool), SidecarError> { - let Some(drag_points) = args.drag_points else { - return Ok((snapshot, false)); - }; - - let previous_frame_hash = host.last_rendered_frame()?.sample_hash(); - host.drag(MouseDragRequest { - webview_id: webview_id.clone(), - from_x: drag_points.from.x, - from_y: drag_points.from.y, - to_x: drag_points.to.x, - to_y: drag_points.to.y, - })?; - wait_for_changed_or_settled_frame(host, webview_id, previous_frame_hash) -} - -fn apply_touch_if_requested( - host: &mut SoftwareServoHost, - webview_id: &ely_domain::WebViewId, - args: &SnapshotArgs, - snapshot: WebViewSnapshot, -) -> Result<(WebViewSnapshot, bool), SidecarError> { - let Some(touch_point) = args.touch_point else { - return Ok((snapshot, false)); - }; - - let previous_frame_hash = host.last_rendered_frame()?.sample_hash(); - host.touch_tap(TouchTapRequest { - webview_id: webview_id.clone(), - x: touch_point.x, - y: touch_point.y, - })?; - wait_for_changed_or_settled_frame(host, webview_id, previous_frame_hash) -} - -fn apply_text_if_requested( - host: &mut SoftwareServoHost, - webview_id: &ely_domain::WebViewId, - args: &SnapshotArgs, - snapshot: WebViewSnapshot, -) -> Result<(WebViewSnapshot, bool), SidecarError> { - let Some(typed_text) = args.typed_text.as_ref() else { - return Ok((snapshot, false)); - }; - - let previous_frame_hash = host.last_rendered_frame()?.sample_hash(); - host.type_text(KeyboardTextRequest { - webview_id: webview_id.clone(), - text: typed_text.clone(), - })?; - wait_for_changed_or_settled_frame(host, webview_id, previous_frame_hash) -} - -fn wait_for_frame( - host: &mut SoftwareServoHost, - webview_id: &ely_domain::WebViewId, - url: &str, -) -> Result { - let started_at = Instant::now(); - let mut latest_rendered_snapshot = None; - let mut visible_frame_hash = None; - let mut visible_frame_last_changed_at = None; - - for _ in 0..WAIT_ITERATIONS { - if started_at.elapsed() >= RENDER_TIMEOUT { - break; - } - - host.tick(); - let snapshot = host.snapshot(webview_id)?; - if snapshot.has_pending_frame() { - host.paint(webview_id)?; - } - - let snapshot = host.snapshot(webview_id)?; - if let Ok(frame) = host.last_rendered_frame() - && frame.non_white_pixel_count() > 0 - && frame.content_pixel_count() > 0 - { - let current_hash = frame.sample_hash(); - if visible_frame_hash != Some(current_hash) { - visible_frame_hash = Some(current_hash); - visible_frame_last_changed_at = Some(Instant::now()); - } - - if snapshot.state() == &WebViewState::Complete { - return Ok(snapshot); - } - - latest_rendered_snapshot = Some(snapshot.clone()); - if snapshot.url().is_some() - && visible_frame_last_changed_at - .is_some_and(|changed_at| changed_at.elapsed() >= VISIBLE_FRAME_SETTLE_TIMEOUT) - { - return Ok(snapshot); - } - } - - thread::sleep(WAIT_INTERVAL); - } - - if let Some(snapshot) = latest_rendered_snapshot { - return Ok(snapshot); - } - - Err(SidecarError::RenderTimeout { - url: url.to_string(), - snapshot: Box::new(host.snapshot(webview_id)?), - }) -} - -fn wait_for_changed_or_settled_frame( - host: &mut SoftwareServoHost, - webview_id: &ely_domain::WebViewId, - previous_frame_hash: u64, -) -> Result<(WebViewSnapshot, bool), SidecarError> { - let started_at = Instant::now(); - let mut latest_snapshot = host.snapshot(webview_id)?; - - for _ in 0..WAIT_ITERATIONS { - if started_at.elapsed() >= INPUT_SETTLE_TIMEOUT { - break; - } - - host.tick(); - let snapshot = host.snapshot(webview_id)?; - if snapshot.has_pending_frame() { - host.paint(webview_id)?; - } - - latest_snapshot = host.snapshot(webview_id)?; - let changed_frame = host.last_rendered_frame().is_ok_and(|frame| { - frame.non_white_pixel_count() > 0 && frame.sample_hash() != previous_frame_hash - }); - if changed_frame { - return Ok((latest_snapshot, true)); - } - - thread::sleep(WAIT_INTERVAL); - } - - Ok((latest_snapshot, false)) -} diff --git a/crates/ely_servo_host/src/bin/ely_servo_sidecar/args.rs b/crates/ely_servo_host/src/bin/ely_servo_sidecar/args.rs deleted file mode 100644 index 92ab9b3..0000000 --- a/crates/ely_servo_host/src/bin/ely_servo_sidecar/args.rs +++ /dev/null @@ -1,395 +0,0 @@ -use std::{env, num::ParseIntError, path::PathBuf}; - -use ely_domain::{ - DEFAULT_ZOOM_PERCENT, ProfileId, SiteOrigin, SitePermissionDecision, SitePermissionFeature, - UrlText, validate_zoom_percent, -}; -use ely_servo_host::RenderingContextKind; -use serde::Deserialize; -use thiserror::Error; - -pub(super) enum SidecarCommand { - Live(LiveArgs), - Snapshot(SnapshotArgs), -} - -pub(super) struct LiveArgs { - pub(super) profile_data_dir: PathBuf, - pub(super) iosurface_mach_service: Option, - /// Rendering context the host's webviews are built against. - /// Defaults to [`RenderingContextKind::Software`], which keeps - /// the binary's behaviour bit-identical to pre-flag builds. - /// `Hardware` is only accepted when the `hardware-render` - /// feature is compiled in (otherwise the `SoftwareServoHost` - /// constructor returns `HardwareRenderUnavailable`). - pub(super) rendering_context_kind: RenderingContextKind, -} - -pub(super) struct SnapshotArgs { - pub(super) url: UrlText, - pub(super) profile_id: ProfileId, - pub(super) profile_data_dir: PathBuf, - pub(super) rgba_out: PathBuf, - pub(super) width: u32, - pub(super) height: u32, - pub(super) scroll_x: i32, - pub(super) scroll_y: i32, - pub(super) page_zoom_percent: u16, - pub(super) click_point: Option, - pub(super) drag_points: Option, - pub(super) touch_point: Option, - pub(super) typed_text: Option, - pub(super) site_permissions: Vec, -} - -#[derive(Clone, Copy)] -pub(super) struct ClickPoint { - pub(super) x: u32, - pub(super) y: u32, -} - -#[derive(Clone, Copy)] -pub(super) struct DragPoints { - pub(super) from: ClickPoint, - pub(super) to: ClickPoint, -} - -pub(super) struct SidecarSitePermission { - pub(super) origin: SiteOrigin, - pub(super) feature: SitePermissionFeature, - pub(super) decision: SitePermissionDecision, -} - -#[derive(Debug, Error)] -pub(super) enum SidecarArgsError { - #[error("missing sidecar command")] - MissingCommand, - - #[error("unknown sidecar command: {value}")] - UnknownCommand { value: String }, - - #[error("missing argument value for {name}")] - MissingArgumentValue { name: &'static str }, - - #[error("missing required argument: {name}")] - MissingRequiredArgument { name: &'static str }, - - #[error("unknown argument: {value}")] - UnknownArgument { value: String }, - - #[error("{name} must be an integer: {value}")] - InvalidInteger { - name: &'static str, - value: String, - #[source] - source: ParseIntError, - }, - - #[error("{name} must be greater than zero")] - ZeroDimension { name: &'static str }, - - #[error("--click-x and --click-y must be provided together")] - IncompleteClickPoint, - - #[error("--drag-from-x, --drag-from-y, --drag-to-x, and --drag-to-y must be provided together")] - IncompleteDragPoints, - - #[error("--touch-x and --touch-y must be provided together")] - IncompleteTouchPoint, - - #[error("{name} path is empty")] - EmptyPath { name: &'static str }, - - #[error("invalid --site-permission JSON: {value}")] - InvalidSitePermissionJson { - value: String, - #[source] - source: serde_json::Error, - }, - - #[error( - "invalid --rendering-context value: {value:?} (expected \"software\" or \ - \"hardware\")" - )] - InvalidRenderingContext { value: String }, - - #[error(transparent)] - Domain(#[from] ely_domain::DomainError), -} - -pub(super) fn parse_env_command() -> Result { - parse_command(env::args()) -} - -fn parse_command( - args: impl IntoIterator, -) -> Result { - let mut args = args.into_iter(); - let _program_name = args.next(); - let command = args.next().ok_or(SidecarArgsError::MissingCommand)?; - - match command.as_str() { - "live" => parse_live_args(args).map(SidecarCommand::Live), - "snapshot" => parse_snapshot_args(args).map(SidecarCommand::Snapshot), - _ => Err(SidecarArgsError::UnknownCommand { value: command }), - } -} - -fn parse_live_args(args: impl IntoIterator) -> Result { - let mut args = args.into_iter(); - let mut profile_data_dir = None; - let mut iosurface_mach_service = None; - let mut rendering_context_kind = RenderingContextKind::default(); - - while let Some(name) = args.next() { - match name.as_str() { - "--profile-data-dir" => { - profile_data_dir = Some(parse_path( - "--profile-data-dir", - next_argument(&mut args, "--profile-data-dir")?, - )?) - } - "--rendering-context" => { - let value = next_argument(&mut args, "--rendering-context")?; - rendering_context_kind = match value.as_str() { - "software" => RenderingContextKind::Software, - "hardware" => RenderingContextKind::Hardware, - _ => return Err(SidecarArgsError::InvalidRenderingContext { value }), - }; - } - "--iosurface-mach-service" => { - iosurface_mach_service = - Some(next_argument(&mut args, "--iosurface-mach-service")?); - } - _ => return Err(SidecarArgsError::UnknownArgument { value: name }), - } - } - - Ok(LiveArgs { - profile_data_dir: profile_data_dir - .ok_or(SidecarArgsError::MissingRequiredArgument { name: "--profile-data-dir" })?, - iosurface_mach_service, - rendering_context_kind, - }) -} - -fn parse_snapshot_args( - args: impl IntoIterator, -) -> Result { - let mut args = args.into_iter(); - let mut url = None; - let mut profile_id = None; - let mut profile_data_dir = None; - let mut rgba_out = None; - let mut width = None; - let mut height = None; - let mut scroll_x = 0; - let mut scroll_y = 0; - let mut page_zoom_percent = DEFAULT_ZOOM_PERCENT; - let mut click_x = None; - let mut click_y = None; - let mut drag_from_x = None; - let mut drag_from_y = None; - let mut drag_to_x = None; - let mut drag_to_y = None; - let mut touch_x = None; - let mut touch_y = None; - let mut typed_text = None; - let mut site_permissions = Vec::new(); - - while let Some(name) = args.next() { - match name.as_str() { - "--url" => url = Some(UrlText::parse(next_argument(&mut args, "--url")?)?), - "--profile-id" => { - profile_id = Some(ProfileId::parse(next_argument(&mut args, "--profile-id")?)?) - } - "--profile-data-dir" => { - profile_data_dir = Some(parse_path( - "--profile-data-dir", - next_argument(&mut args, "--profile-data-dir")?, - )?) - } - "--rgba-out" => { - rgba_out = Some(parse_path("--rgba-out", next_argument(&mut args, "--rgba-out")?)?) - } - "--width" => { - width = Some(parse_dimension("--width", next_argument(&mut args, "--width")?)?) - } - "--height" => { - height = Some(parse_dimension("--height", next_argument(&mut args, "--height")?)?) - } - "--scroll-x" => { - scroll_x = - parse_scroll_delta("--scroll-x", next_argument(&mut args, "--scroll-x")?)? - } - "--scroll-y" => { - scroll_y = - parse_scroll_delta("--scroll-y", next_argument(&mut args, "--scroll-y")?)? - } - "--page-zoom-percent" => { - page_zoom_percent = parse_zoom_percent( - "--page-zoom-percent", - next_argument(&mut args, "--page-zoom-percent")?, - )? - } - "--click-x" => { - click_x = Some(parse_click_coordinate( - "--click-x", - next_argument(&mut args, "--click-x")?, - )?) - } - "--click-y" => { - click_y = Some(parse_click_coordinate( - "--click-y", - next_argument(&mut args, "--click-y")?, - )?) - } - "--drag-from-x" => { - drag_from_x = Some(parse_click_coordinate( - "--drag-from-x", - next_argument(&mut args, "--drag-from-x")?, - )?) - } - "--drag-from-y" => { - drag_from_y = Some(parse_click_coordinate( - "--drag-from-y", - next_argument(&mut args, "--drag-from-y")?, - )?) - } - "--drag-to-x" => { - drag_to_x = Some(parse_click_coordinate( - "--drag-to-x", - next_argument(&mut args, "--drag-to-x")?, - )?) - } - "--drag-to-y" => { - drag_to_y = Some(parse_click_coordinate( - "--drag-to-y", - next_argument(&mut args, "--drag-to-y")?, - )?) - } - "--touch-x" => { - touch_x = Some(parse_click_coordinate( - "--touch-x", - next_argument(&mut args, "--touch-x")?, - )?) - } - "--touch-y" => { - touch_y = Some(parse_click_coordinate( - "--touch-y", - next_argument(&mut args, "--touch-y")?, - )?) - } - "--type-text" => typed_text = Some(next_argument(&mut args, "--type-text")?), - "--site-permission" => site_permissions - .push(parse_site_permission(next_argument(&mut args, "--site-permission")?)?), - _ => return Err(SidecarArgsError::UnknownArgument { value: name }), - } - } - - let click_point = match (click_x, click_y) { - (Some(x), Some(y)) => Some(ClickPoint { x, y }), - (None, None) => None, - _ => return Err(SidecarArgsError::IncompleteClickPoint), - }; - let drag_points = match (drag_from_x, drag_from_y, drag_to_x, drag_to_y) { - (Some(from_x), Some(from_y), Some(to_x), Some(to_y)) => Some(DragPoints { - from: ClickPoint { x: from_x, y: from_y }, - to: ClickPoint { x: to_x, y: to_y }, - }), - (None, None, None, None) => None, - _ => return Err(SidecarArgsError::IncompleteDragPoints), - }; - let touch_point = match (touch_x, touch_y) { - (Some(x), Some(y)) => Some(ClickPoint { x, y }), - (None, None) => None, - _ => return Err(SidecarArgsError::IncompleteTouchPoint), - }; - - Ok(SnapshotArgs { - url: url.ok_or(SidecarArgsError::MissingRequiredArgument { name: "--url" })?, - profile_id: profile_id - .ok_or(SidecarArgsError::MissingRequiredArgument { name: "--profile-id" })?, - profile_data_dir: profile_data_dir - .ok_or(SidecarArgsError::MissingRequiredArgument { name: "--profile-data-dir" })?, - rgba_out: rgba_out - .ok_or(SidecarArgsError::MissingRequiredArgument { name: "--rgba-out" })?, - width: width.ok_or(SidecarArgsError::MissingRequiredArgument { name: "--width" })?, - height: height.ok_or(SidecarArgsError::MissingRequiredArgument { name: "--height" })?, - scroll_x, - scroll_y, - page_zoom_percent, - click_point, - drag_points, - touch_point, - typed_text, - site_permissions, - }) -} - -#[derive(Deserialize)] -struct SitePermissionArg { - origin: String, - feature: String, - decision: String, -} - -fn parse_site_permission(value: String) -> Result { - let parsed: SitePermissionArg = serde_json::from_str(&value) - .map_err(|source| SidecarArgsError::InvalidSitePermissionJson { value, source })?; - - Ok(SidecarSitePermission { - origin: SiteOrigin::parse(parsed.origin)?, - feature: SitePermissionFeature::parse(parsed.feature.as_str())?, - decision: SitePermissionDecision::parse(parsed.decision.as_str())?, - }) -} - -fn next_argument( - args: &mut impl Iterator, - name: &'static str, -) -> Result { - args.next().ok_or(SidecarArgsError::MissingArgumentValue { name }) -} - -fn parse_dimension(name: &'static str, value: String) -> Result { - let dimension = value.parse::().map_err(|source| SidecarArgsError::InvalidInteger { - name, - value, - source, - })?; - if dimension == 0 { - return Err(SidecarArgsError::ZeroDimension { name }); - } - - Ok(dimension) -} - -fn parse_scroll_delta(name: &'static str, value: String) -> Result { - value.parse::().map_err(|source| SidecarArgsError::InvalidInteger { name, value, source }) -} - -fn parse_click_coordinate(name: &'static str, value: String) -> Result { - value.parse::().map_err(|source| SidecarArgsError::InvalidInteger { name, value, source }) -} - -fn parse_zoom_percent(name: &'static str, value: String) -> Result { - let percent = value.parse::().map_err(|source| SidecarArgsError::InvalidInteger { - name, - value, - source, - })?; - Ok(validate_zoom_percent(percent)?) -} - -fn parse_path(name: &'static str, value: String) -> Result { - if value.trim().is_empty() { - return Err(SidecarArgsError::EmptyPath { name }); - } - - Ok(PathBuf::from(value)) -} - -#[cfg(test)] -#[path = "args_tests.rs"] -mod tests; diff --git a/crates/ely_servo_host/src/bin/ely_servo_sidecar/args_tests.rs b/crates/ely_servo_host/src/bin/ely_servo_sidecar/args_tests.rs deleted file mode 100644 index aa733a4..0000000 --- a/crates/ely_servo_host/src/bin/ely_servo_sidecar/args_tests.rs +++ /dev/null @@ -1,183 +0,0 @@ -use std::{env, path::PathBuf}; - -use super::{SidecarArgsError, SidecarCommand, parse_command}; -use ely_domain::{ - DEFAULT_ZOOM_PERCENT, DomainError, ProfileId, SitePermissionDecision, SitePermissionFeature, -}; -use ely_servo_host::RenderingContextKind; - -#[test] -fn parses_snapshot_profile_identity() -> Result<(), SidecarArgsError> { - let profile_id = ProfileId::new(); - let profile_data_dir = env::temp_dir().join(profile_id.as_str()); - let args = parse_snapshot_command(&profile_id, profile_data_dir.clone())?; - - assert_eq!(args.profile_id, profile_id); - assert_eq!(args.profile_data_dir, profile_data_dir); - assert_eq!(args.page_zoom_percent, DEFAULT_ZOOM_PERCENT); - Ok(()) -} - -#[test] -fn rejects_invalid_snapshot_profile_id() { - let command = parse_command( - [ - "ely_servo_sidecar", - "snapshot", - "--url", - "https://example.com", - "--profile-id", - "profile_invalid", - "--profile-data-dir", - "/tmp/profile", - "--rgba-out", - "/tmp/frame.rgba", - "--width", - "64", - "--height", - "64", - ] - .into_iter() - .map(str::to_string), - ); - - assert!(matches!(command, Err(SidecarArgsError::Domain(DomainError::InvalidEntityId { .. })))); -} - -#[test] -fn parses_snapshot_site_permissions() -> Result<(), SidecarArgsError> { - let profile_id = ProfileId::new(); - let profile_data_dir = env::temp_dir().join(profile_id.as_str()); - let mut command = snapshot_command_args(&profile_id, profile_data_dir); - command.push("--site-permission".to_string()); - command.push( - r#"{"origin":"https://example.com","feature":"camera","decision":"allow-once"}"# - .to_string(), - ); - - let args = snapshot_args(parse_command(command)?); - assert_eq!(args.site_permissions.len(), 1); - let permission = &args.site_permissions[0]; - assert_eq!(permission.origin.as_str(), "https://example.com"); - assert_eq!(permission.feature, SitePermissionFeature::Camera); - assert_eq!(permission.decision, SitePermissionDecision::AllowOnce); - Ok(()) -} - -#[test] -fn parses_snapshot_page_zoom_percent() -> Result<(), SidecarArgsError> { - let profile_id = ProfileId::new(); - let profile_data_dir = env::temp_dir().join(profile_id.as_str()); - let mut command = snapshot_command_args(&profile_id, profile_data_dir); - command.push("--page-zoom-percent".to_string()); - command.push("125".to_string()); - - let args = snapshot_args(parse_command(command)?); - assert_eq!(args.page_zoom_percent, 125); - Ok(()) -} - -#[test] -fn rejects_out_of_range_snapshot_page_zoom_percent() { - let profile_id = ProfileId::new(); - let profile_data_dir = env::temp_dir().join(profile_id.as_str()); - let mut command = snapshot_command_args(&profile_id, profile_data_dir); - command.push("--page-zoom-percent".to_string()); - command.push("5".to_string()); - - assert!(matches!( - parse_command(command), - Err(SidecarArgsError::Domain(DomainError::InvalidZoomPercent { value: 5, .. })) - )); -} - -fn parse_snapshot_command( - profile_id: &ProfileId, - profile_data_dir: PathBuf, -) -> Result { - Ok(snapshot_args(parse_command(snapshot_command_args(profile_id, profile_data_dir))?)) -} - -fn snapshot_args(command: SidecarCommand) -> super::SnapshotArgs { - match command { - SidecarCommand::Snapshot(args) => args, - SidecarCommand::Live(_) => unreachable!("expected snapshot command"), - } -} - -fn snapshot_command_args(profile_id: &ProfileId, profile_data_dir: PathBuf) -> Vec { - [ - "ely_servo_sidecar".to_string(), - "snapshot".to_string(), - "--url".to_string(), - "https://example.com".to_string(), - "--profile-id".to_string(), - profile_id.as_str().to_string(), - "--profile-data-dir".to_string(), - profile_data_dir.display().to_string(), - "--rgba-out".to_string(), - "/tmp/frame.rgba".to_string(), - "--width".to_string(), - "64".to_string(), - "--height".to_string(), - "64".to_string(), - ] - .into_iter() - .collect() -} - -fn parse_live(extra_args: &[&str]) -> Result { - let base = ["ely_servo_sidecar", "live", "--profile-data-dir", "/tmp/sidecar-live"]; - let argv: Vec = - base.iter().chain(extra_args.iter()).map(|s| (*s).to_string()).collect(); - let SidecarCommand::Live(args) = parse_command(argv)? else { - return Err(SidecarArgsError::UnknownCommand { - value: "live-extracted-as-snapshot".into(), - }); - }; - Ok(args) -} - -#[test] -fn live_defaults_rendering_context_to_software() -> Result<(), SidecarArgsError> { - let args = parse_live(&[])?; - assert_eq!(args.rendering_context_kind, RenderingContextKind::Software); - Ok(()) -} - -#[test] -fn live_accepts_explicit_software_rendering_context() -> Result<(), SidecarArgsError> { - let args = parse_live(&["--rendering-context", "software"])?; - assert_eq!(args.rendering_context_kind, RenderingContextKind::Software); - Ok(()) -} - -#[test] -fn live_accepts_explicit_hardware_rendering_context() -> Result<(), SidecarArgsError> { - let args = parse_live(&["--rendering-context", "hardware"])?; - assert_eq!(args.rendering_context_kind, RenderingContextKind::Hardware); - Ok(()) -} - -#[test] -fn live_accepts_iosurface_mach_service_name() -> Result<(), SidecarArgsError> { - let args = parse_live(&["--iosurface-mach-service", "com.ely.test.iosurface"])?; - assert_eq!(args.iosurface_mach_service.as_deref(), Some("com.ely.test.iosurface")); - Ok(()) -} - -#[test] -fn live_rejects_unknown_rendering_context_value() { - assert!(matches!( - parse_live(&["--rendering-context", "gpu"]), - Err(SidecarArgsError::InvalidRenderingContext { value }) if value == "gpu" - )); -} - -#[test] -fn live_requires_rendering_context_value() { - assert!(matches!( - parse_live(&["--rendering-context"]), - Err(SidecarArgsError::MissingArgumentValue { name: "--rendering-context" }) - )); -} diff --git a/crates/ely_servo_host/src/bin/ely_servo_sidecar/iosurface_mach.rs b/crates/ely_servo_host/src/bin/ely_servo_sidecar/iosurface_mach.rs deleted file mode 100644 index 1dd0b45..0000000 --- a/crates/ely_servo_host/src/bin/ely_servo_sidecar/iosurface_mach.rs +++ /dev/null @@ -1,133 +0,0 @@ -use std::{ffi::CString, mem, time::Duration}; - -use mach2::{ - bootstrap::{bootstrap_look_up, bootstrap_port}, - kern_return::KERN_SUCCESS, - mach_port::mach_port_deallocate, - message::{ - MACH_MSG_SUCCESS, MACH_MSG_TYPE_COPY_SEND, MACH_MSG_TYPE_MOVE_SEND, MACH_MSGH_BITS, - MACH_MSGH_BITS_COMPLEX, MACH_SEND_MSG, MACH_SEND_TIMEOUT, mach_msg, mach_msg_body_t, - mach_msg_header_t, mach_msg_port_descriptor_t, - }, - port::{MACH_PORT_NULL, mach_port_t}, - traps::mach_task_self, -}; -use thiserror::Error; - -use super::live_protocol::{LiveOutcome, LiveSidecarError}; - -const IOSURFACE_PORT_MESSAGE_ID: i32 = 0x454c_5901; -const SEND_TIMEOUT: Duration = Duration::from_secs(1); - -pub(super) struct IOSurfaceMachSender { - send_port: mach_port_t, -} - -#[derive(Debug, Error)] -pub(super) enum IOSurfaceMachError { - #[error("Mach service name contains an interior nul byte")] - InvalidServiceName, - - #[error("bootstrap_look_up returned {code}")] - LookupService { code: i32 }, - - #[error("mach_msg send returned {code}")] - Send { code: i32 }, -} - -impl IOSurfaceMachSender { - pub(super) fn connect(service_name: &str) -> Result { - let service_name = - CString::new(service_name).map_err(|_| IOSurfaceMachError::InvalidServiceName)?; - let mut send_port = MACH_PORT_NULL; - #[expect(unsafe_code)] - let result = - unsafe { bootstrap_look_up(bootstrap_port, service_name.as_ptr(), &mut send_port) }; - if result != KERN_SUCCESS { - return Err(IOSurfaceMachError::LookupService { code: result }); - } - Ok(Self { send_port }) - } - - pub(super) fn send_surface_port( - &mut self, - surface_id: u64, - mach_port: mach_port_t, - ) -> Result<(), IOSurfaceMachError> { - let mut message = IOSurfacePortMessage { - header: mach_msg_header_t { - msgh_bits: MACH_MSGH_BITS(MACH_MSG_TYPE_COPY_SEND, 0) | MACH_MSGH_BITS_COMPLEX, - msgh_size: mem::size_of::() as u32, - msgh_remote_port: self.send_port, - msgh_local_port: MACH_PORT_NULL, - msgh_voucher_port: MACH_PORT_NULL, - msgh_id: IOSURFACE_PORT_MESSAGE_ID, - }, - body: mach_msg_body_t { msgh_descriptor_count: 1 }, - surface_port: mach_msg_port_descriptor_t::new(mach_port, MACH_MSG_TYPE_MOVE_SEND), - surface_id, - }; - #[expect(unsafe_code)] - let result = unsafe { - mach_msg( - &mut message.header, - MACH_SEND_MSG | MACH_SEND_TIMEOUT, - message.header.msgh_size, - 0, - MACH_PORT_NULL, - timeout_millis(SEND_TIMEOUT), - MACH_PORT_NULL, - ) - }; - if result != MACH_MSG_SUCCESS { - destroy_message(&mut message); - return Err(IOSurfaceMachError::Send { code: result }); - } - Ok(()) - } -} - -impl Drop for IOSurfaceMachSender { - fn drop(&mut self) { - #[expect(unsafe_code)] - let task = unsafe { mach_task_self() }; - #[expect(unsafe_code)] - unsafe { - let _ = mach_port_deallocate(task, self.send_port); - } - } -} - -pub(super) fn send_surface_port_if_needed( - sender: Option<&mut IOSurfaceMachSender>, - outcome: &mut Result, -) { - let (Some(sender), Ok(live_outcome)) = (sender, outcome.as_ref()) else { - return; - }; - let Some(handle) = live_outcome.response.surface_handle else { - return; - }; - if let Err(error) = sender.send_surface_port(handle.surface_id, handle.mach_port_name) { - *outcome = Err(LiveSidecarError::IOSurfaceMach(error)); - } -} - -#[repr(C)] -struct IOSurfacePortMessage { - header: mach_msg_header_t, - body: mach_msg_body_t, - surface_port: mach_msg_port_descriptor_t, - surface_id: u64, -} - -fn timeout_millis(timeout: Duration) -> u32 { - u32::try_from(timeout.as_millis()).unwrap_or(u32::MAX).max(1) -} - -fn destroy_message(message: &mut IOSurfacePortMessage) { - #[expect(unsafe_code)] - unsafe { - mach2::message::mach_msg_destroy(&mut message.header); - } -} diff --git a/crates/ely_servo_host/src/bin/ely_servo_sidecar/live.rs b/crates/ely_servo_host/src/bin/ely_servo_sidecar/live.rs deleted file mode 100644 index e5b692a..0000000 --- a/crates/ely_servo_host/src/bin/ely_servo_sidecar/live.rs +++ /dev/null @@ -1,476 +0,0 @@ -use std::{ - collections::{HashMap, HashSet}, - fs, - io::{self, BufRead}, - time::Instant, -}; - -use ely_domain::{ProfileId, TabId, UrlText}; -#[cfg(all(feature = "hardware-render", target_os = "macos"))] -use ely_servo_host::ServoHostError; -use ely_servo_host::{ - IOSurfaceIdentity, NavigationRequest, RenderingContextKind, ServoHost, ServoSurfaceSize, - SoftwareServoHost, -}; - -use super::args::LiveArgs; -#[cfg(all(feature = "hardware-render", target_os = "macos"))] -use super::iosurface_mach::{IOSurfaceMachSender, send_surface_port_if_needed}; -use super::live_output::{populate_surface_fields, write_outcome}; -pub(super) use super::live_protocol::LiveSidecarError; -use super::live_protocol::{LiveFrameReport, LiveOutcome, LiveRequest, PartialFrameTimings}; -use super::live_session::{ - LiveInput, LiveSession, apply_input, apply_layout, apply_permissions, ensure_session, -}; -use super::perf::{FramePerfAggregator, FramePerfSummary, elapsed_ns}; - -pub(super) fn run_live(args: LiveArgs) -> Result<(), LiveSidecarError> { - let LiveArgs { profile_data_dir, iosurface_mach_service, rendering_context_kind } = args; - let publish_readback_surface_fields = true; - let require_client_ready_surfaces = iosurface_mach_service.is_some(); - fs::create_dir_all(&profile_data_dir)?; - let context_label = rendering_context_label(rendering_context_kind); - let mut host = SoftwareServoHost::new_with_config_dir_and_kind( - ServoSurfaceSize::new(1, 1), - Some(profile_data_dir), - rendering_context_kind, - )?; - #[cfg(all(feature = "hardware-render", target_os = "macos"))] - let mut iosurface_mach_sender = - iosurface_mach_service.as_deref().map(IOSurfaceMachSender::connect).transpose()?; - #[cfg(not(all(feature = "hardware-render", target_os = "macos")))] - let _ = iosurface_mach_service; - let mut sessions = HashMap::new(); - let mut perf = - FramePerfAggregator::new(context_label, FramePerfAggregator::DEFAULT_WINDOW_SIZE); - let mut pending_summary: Option = None; - let mut published_surface_ids: HashMap> = HashMap::new(); - let stdin = io::stdin(); - let mut stdout = io::stdout().lock(); - - for line in stdin.lock().lines() { - let line = line?; - if line.trim().is_empty() { - continue; - } - - // `frame_started_at` is the honest start of the end-to-end - // frame: a request just arrived and we're about to do - // everything required to put bytes back on the pipe. The - // matching stop is the `stdout.flush()` inside - // `write_outcome`. - let frame_started_at = Instant::now(); - let outcome = match serde_json::from_str::(&line) { - Ok(request) => handle_request( - &mut host, - &mut sessions, - &mut published_surface_ids, - rendering_context_kind, - publish_readback_surface_fields, - require_client_ready_surfaces, - request, - ), - Err(error) => Err(LiveSidecarError::Json(error)), - }; - #[cfg(all(feature = "hardware-render", target_os = "macos"))] - let outcome = { - let mut outcome = outcome; - send_surface_port_if_needed(iosurface_mach_sender.as_mut(), &mut outcome); - outcome - }; - write_outcome(&mut stdout, &mut perf, &mut pending_summary, outcome, frame_started_at)?; - } - - Ok(()) -} - -const fn rendering_context_label(kind: RenderingContextKind) -> &'static str { - match kind { - RenderingContextKind::Software => "software", - RenderingContextKind::Hardware => "hardware", - } -} - -fn handle_request( - host: &mut SoftwareServoHost, - sessions: &mut HashMap, - published_surface_ids: &mut HashMap>, - rendering_context_kind: RenderingContextKind, - publish_readback_surface_fields: bool, - require_client_ready_surfaces: bool, - request: LiveRequest, -) -> Result { - match request { - LiveRequest::Ensure { - tab_id, - profile_id, - url, - width, - height, - page_zoom_percent, - device_pixel_ratio, - scroll_delta_x, - scroll_delta_y, - scroll_point_x, - scroll_point_y, - click_x, - click_y, - hover_x, - hover_y, - typed_text, - site_permissions, - ready_surface_ids, - } => { - let tab = TabId::parse(tab_id.clone())?; - let profile = ProfileId::parse(profile_id)?; - let url = UrlText::parse(url)?; - let session = - ensure_session(host, sessions, tab_id.clone(), &tab, &profile, width, height)?; - - if apply_layout(host, session, width, height, page_zoom_percent, device_pixel_ratio)? { - session.awaiting_visible_frame = true; - } - apply_permissions(host, session, &profile, site_permissions)?; - if session.requested_url != url.as_str() { - host.navigate(NavigationRequest { - webview_id: session.webview_id.clone(), - tab_id: tab, - url: url.clone(), - })?; - session.requested_url = url.as_str().to_string(); - session.scroll_x = 0; - session.scroll_y = 0; - session.awaiting_visible_frame = true; - // New URL: the previous tab's pixels are no longer - // valid evidence that "we have visible content"; let - // the gate skip blank loading frames again. - session.ever_visible_frame = false; - } - let input = LiveInput { - scroll_delta_x, - scroll_delta_y, - scroll_point_x, - scroll_point_y, - click_x, - click_y, - hover_x, - hover_y, - typed_text, - }; - if apply_input(host, session, input)? { - // The app tick calls this sidecar synchronously from - // GPUI's update path. Mark that a fresh frame is - // desired, then let poll_frame take one event-loop - // step; a later 16 ms app tick will poll again if - // Servo has not painted yet. - session.awaiting_visible_frame = true; - } - let webview_id = session.webview_id.clone(); - let mut outcome = poll_frame( - host, - session, - rendering_context_kind, - payloadless_readiness( - &tab_id, - published_surface_ids, - &ready_surface_ids, - require_client_ready_surfaces, - ), - )?; - populate_surface_fields( - host, - &webview_id, - &tab_id, - published_surface_ids, - publish_readback_surface_fields, - &mut outcome, - ); - Ok(outcome) - } - LiveRequest::Poll { tab_id, ready_surface_ids } => { - let Some(session) = sessions.get_mut(&tab_id) else { - return Ok(LiveOutcome::empty()); - }; - let webview_id = session.webview_id.clone(); - let mut outcome = poll_frame( - host, - session, - rendering_context_kind, - payloadless_readiness( - &tab_id, - published_surface_ids, - &ready_surface_ids, - require_client_ready_surfaces, - ), - )?; - populate_surface_fields( - host, - &webview_id, - &tab_id, - published_surface_ids, - publish_readback_surface_fields, - &mut outcome, - ); - Ok(outcome) - } - LiveRequest::Close { tab_id } => { - if let Some(session) = sessions.remove(&tab_id) { - host.close_webview(&session.webview_id); - } - published_surface_ids.remove(&tab_id); - Ok(LiveOutcome::empty()) - } - } -} - -fn poll_frame( - host: &mut SoftwareServoHost, - session: &mut LiveSession, - rendering_context_kind: RenderingContextKind, - readiness: PayloadlessReadiness<'_>, -) -> Result { - host.tick(); - let snapshot = host.snapshot(&session.webview_id)?; - let has_pending_frame = snapshot.has_pending_frame(); - if !should_paint_live_frame(has_pending_frame, session.awaiting_visible_frame) { - return Ok(LiveOutcome::empty()); - } - - let (outcome, has_visible_content) = - paint_pending_frame(host, session, rendering_context_kind, readiness, has_pending_frame)?; - if has_visible_content { - session.awaiting_visible_frame = false; - session.ever_visible_frame = true; - return Ok(outcome); - } - if !session.awaiting_visible_frame { - return Ok(outcome); - } - - Ok(LiveOutcome::empty()) -} - -fn should_paint_live_frame(has_pending_frame: bool, awaiting_visible_frame: bool) -> bool { - has_pending_frame || awaiting_visible_frame -} - -fn paint_pending_frame( - host: &mut SoftwareServoHost, - session: &mut LiveSession, - rendering_context_kind: RenderingContextKind, - readiness: PayloadlessReadiness<'_>, - has_pending_frame: bool, -) -> Result<(LiveOutcome, bool), LiveSidecarError> { - #[cfg(not(all(feature = "hardware-render", target_os = "macos")))] - let _ = readiness; - - match rendering_context_kind { - RenderingContextKind::Software => paint_readback_frame(host, session, !has_pending_frame), - #[cfg(all(feature = "hardware-render", target_os = "macos"))] - RenderingContextKind::Hardware => { - paint_hardware_surface_frame(host, session, readiness, has_pending_frame) - } - #[cfg(not(all(feature = "hardware-render", target_os = "macos")))] - RenderingContextKind::Hardware => paint_readback_frame(host, session, !has_pending_frame), - } -} - -fn paint_readback_frame( - host: &mut SoftwareServoHost, - session: &LiveSession, - wait_for_completion: bool, -) -> Result<(LiveOutcome, bool), LiveSidecarError> { - let paint_started_at = Instant::now(); - host.paint_with_readback(&session.webview_id, wait_for_completion)?; - let snapshot = host.snapshot(&session.webview_id)?; - let frame = host.last_rendered_frame()?; - let paint_ns = elapsed_ns(paint_started_at); - let encode_started_at = Instant::now(); - let has_visible_content = session.ever_visible_frame - || (frame.non_white_pixel_count() > 0 && frame.content_pixel_count() > 0); - let report = LiveFrameReport::new(&snapshot, &frame, session.device_pixel_ratio()); - let encode_ns = elapsed_ns(encode_started_at); - let timings = PartialFrameTimings { paint_ns, encode_ns }; - Ok((LiveOutcome::from_frame(report, frame, timings), has_visible_content)) -} - -#[cfg(all(feature = "hardware-render", target_os = "macos"))] -fn paint_hardware_surface_frame( - host: &mut SoftwareServoHost, - session: &LiveSession, - readiness: PayloadlessReadiness<'_>, - has_pending_frame: bool, -) -> Result<(LiveOutcome, bool), LiveSidecarError> { - if !session.ever_visible_frame { - return paint_initial_hardware_surface_frame(host, session, !has_pending_frame); - } - if !payloadless_surface_pool_ready(readiness, session.width, session.height) { - return paint_readback_frame(host, session, !has_pending_frame); - } - let (outcome, _) = paint_hardware_surface_report(host, session, !has_pending_frame)?; - Ok((outcome, true)) -} - -#[cfg(all(feature = "hardware-render", target_os = "macos"))] -fn paint_initial_hardware_surface_frame( - host: &mut SoftwareServoHost, - session: &LiveSession, - wait_for_completion: bool, -) -> Result<(LiveOutcome, bool), LiveSidecarError> { - let paint_started_at = Instant::now(); - host.paint_with_readback(&session.webview_id, wait_for_completion)?; - let snapshot = host.snapshot(&session.webview_id)?; - let frame = host.last_rendered_frame()?; - let paint_ns = elapsed_ns(paint_started_at); - let encode_started_at = Instant::now(); - let report = LiveFrameReport::new(&snapshot, &frame, session.device_pixel_ratio()); - let has_visible_content = frame.non_white_pixel_count() > 0 && frame.content_pixel_count() > 0; - let encode_ns = elapsed_ns(encode_started_at); - let timings = PartialFrameTimings { paint_ns, encode_ns }; - Ok((LiveOutcome::from_frame(report, frame, timings), has_visible_content)) -} - -#[cfg(all(feature = "hardware-render", target_os = "macos"))] -fn paint_hardware_surface_report( - host: &mut SoftwareServoHost, - session: &LiveSession, - wait_for_completion: bool, -) -> Result<(LiveOutcome, IOSurfaceIdentity), LiveSidecarError> { - let paint_started_at = Instant::now(); - host.paint_without_readback_with_completion(&session.webview_id, wait_for_completion)?; - let snapshot = host.snapshot(&session.webview_id)?; - let identity = host.peek_iosurface_identity(&session.webview_id)?.ok_or_else(|| { - ServoHostError::HardwareSurfaceUnavailable { id: session.webview_id.clone() } - })?; - let paint_ns = elapsed_ns(paint_started_at); - let encode_started_at = Instant::now(); - let report = LiveFrameReport::from_surface( - &snapshot, - identity.width, - identity.height, - session.device_pixel_ratio(), - ); - let encode_ns = elapsed_ns(encode_started_at); - let timings = PartialFrameTimings { paint_ns, encode_ns }; - Ok((LiveOutcome::from_report(report, timings), identity)) -} - -#[cfg(all(feature = "hardware-render", target_os = "macos"))] -fn payloadless_surface_pool_ready( - readiness: PayloadlessReadiness<'_>, - width: u32, - height: u32, -) -> bool { - let Some(published) = readiness.published_surface_ids.get(readiness.tab_id) else { - return false; - }; - let matching = published - .iter() - .filter(|identity| identity.width == width && identity.height == height) - .copied() - .collect::>(); - if matching.is_empty() { - return false; - } - !readiness.require_client_ready_surfaces - || matching - .iter() - .any(|identity| readiness.ready_surface_ids.contains(&identity.surface_id)) -} - -#[derive(Clone, Copy)] -struct PayloadlessReadiness<'a> { - #[cfg(all(feature = "hardware-render", target_os = "macos"))] - tab_id: &'a str, - #[cfg(all(feature = "hardware-render", target_os = "macos"))] - published_surface_ids: &'a HashMap>, - #[cfg(all(feature = "hardware-render", target_os = "macos"))] - ready_surface_ids: &'a [u64], - #[cfg(all(feature = "hardware-render", target_os = "macos"))] - require_client_ready_surfaces: bool, - #[cfg(not(all(feature = "hardware-render", target_os = "macos")))] - _marker: std::marker::PhantomData<&'a ()>, -} - -fn payloadless_readiness<'a>( - tab_id: &'a str, - published_surface_ids: &'a HashMap>, - ready_surface_ids: &'a [u64], - require_client_ready_surfaces: bool, -) -> PayloadlessReadiness<'a> { - #[cfg(all(feature = "hardware-render", target_os = "macos"))] - { - PayloadlessReadiness { - tab_id, - published_surface_ids, - ready_surface_ids, - require_client_ready_surfaces, - } - } - #[cfg(not(all(feature = "hardware-render", target_os = "macos")))] - { - let _ = (tab_id, published_surface_ids, ready_surface_ids, require_client_ready_surfaces); - PayloadlessReadiness { _marker: std::marker::PhantomData } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn awaiting_visible_frame_forces_paint_without_pending_flag() { - assert!(should_paint_live_frame(false, true)); - } - - #[test] - fn idle_poll_waits_for_pending_frame() { - assert!(!should_paint_live_frame(false, false)); - assert!(should_paint_live_frame(true, false)); - } - - #[cfg(all(feature = "hardware-render", target_os = "macos"))] - #[test] - fn payloadless_pool_accepts_one_client_ready_surface() { - let published = published_identities([identity(7, 800, 600), identity(8, 800, 600)]); - - assert!(!payloadless_surface_pool_ready(readiness(&published, &[], true), 800, 600)); - assert!(payloadless_surface_pool_ready(readiness(&published, &[7], true), 800, 600)); - } - - #[cfg(all(feature = "hardware-render", target_os = "macos"))] - #[test] - fn payloadless_pool_uses_published_surfaces_for_no_mach_clients() { - let published = published_identities([identity(7, 800, 600), identity(8, 800, 600)]); - - assert!(payloadless_surface_pool_ready(readiness(&published, &[], false), 800, 600)); - } - - #[cfg(all(feature = "hardware-render", target_os = "macos"))] - fn readiness<'a>( - published_surface_ids: &'a HashMap>, - ready_surface_ids: &'a [u64], - require_client_ready_surfaces: bool, - ) -> PayloadlessReadiness<'a> { - PayloadlessReadiness { - tab_id: "tab", - published_surface_ids, - ready_surface_ids, - require_client_ready_surfaces, - } - } - - #[cfg(all(feature = "hardware-render", target_os = "macos"))] - fn published_identities( - identities: [IOSurfaceIdentity; 2], - ) -> HashMap> { - let mut published = HashMap::new(); - published.insert("tab".to_string(), identities.into_iter().collect()); - published - } - - #[cfg(all(feature = "hardware-render", target_os = "macos"))] - fn identity(surface_id: u64, width: u32, height: u32) -> IOSurfaceIdentity { - IOSurfaceIdentity { surface_id, width, height } - } -} diff --git a/crates/ely_servo_host/src/bin/ely_servo_sidecar/live_output.rs b/crates/ely_servo_host/src/bin/ely_servo_sidecar/live_output.rs deleted file mode 100644 index 38736e9..0000000 --- a/crates/ely_servo_host/src/bin/ely_servo_sidecar/live_output.rs +++ /dev/null @@ -1,348 +0,0 @@ -use std::{ - collections::{HashMap, HashSet}, - io::Write, - time::{Duration, Instant}, -}; - -#[cfg(any(test, all(feature = "hardware-render", target_os = "macos")))] -use ely_servo_host::IOSurfaceHandle; -use ely_servo_host::{IOSurfaceIdentity, SoftwareServoHost}; - -use super::live_protocol::{LiveOutcome, LiveSidecarError, PartialFrameTimings}; -use super::perf::{FramePerfAggregator, FramePerfSummary, FrameStageTimings, elapsed_ns}; - -/// Populate the hardware surface protocol fields on `outcome`. Readback -/// warm-up frames publish IOSurface handles so the app can import them -/// on its dedicated importer thread before steady-state payloadless -/// frames select the rotating surface ids. Two pieces of state ride out -/// together: -/// -/// * `current_surface_id` — set on every payload-bearing hardware -/// frame so the receiver knows which previously-imported -/// `MTLTexture` to sample THIS frame. surfman's attached swap -/// chain rotates front/back surfaces, so this alternates between -/// a small set of ids. -/// * `surface_handle` — populated only the first time the sidecar -/// sees a given `surface_id`; the receiver imports the IOSurface -/// once and caches the resulting Metal texture. Minting a fresh -/// mach port per frame would leak ports — `IOSurfaceCreateMachPort` -/// hands out a new send right each call and they don't free -/// automatically until the receiver `mach_port_deallocate`s. -pub(super) fn populate_surface_fields( - host: &SoftwareServoHost, - webview_id: &ely_domain::WebViewId, - tab_id: &str, - published_surface_ids: &mut HashMap>, - publish_readback_surface_fields: bool, - outcome: &mut LiveOutcome, -) { - if outcome.response.frame.is_none() { - return; - } - if outcome.frame.is_some() && !publish_readback_surface_fields { - return; - } - #[cfg(all(feature = "hardware-render", target_os = "macos"))] - { - let Ok(Some(identity)) = host.peek_iosurface_identity(webview_id) else { - return; - }; - if let Err(message) = require_report_matches_surface_identity(outcome, identity) { - *outcome = LiveOutcome::error(message); - return; - } - let handle = if surface_has_been_published(published_surface_ids, tab_id, identity) { - None - } else { - host.current_iosurface_handle(webview_id).ok().flatten() - }; - let publication = surface_publication_for(published_surface_ids, tab_id, identity, handle); - outcome.response.current_surface_id = publication.current_surface_id; - outcome.response.surface_handle = publication.surface_handle; - } - #[cfg(not(all(feature = "hardware-render", target_os = "macos")))] - { - let _ = (host, webview_id, tab_id, published_surface_ids, publish_readback_surface_fields); - } -} - -#[cfg(any(test, all(feature = "hardware-render", target_os = "macos")))] -fn surface_has_been_published( - published_surface_ids: &HashMap>, - tab_id: &str, - identity: IOSurfaceIdentity, -) -> bool { - published_surface_ids.get(tab_id).is_some_and(|published| published.contains(&identity)) -} - -#[cfg(any(test, all(feature = "hardware-render", target_os = "macos")))] -#[derive(Clone, Copy)] -struct SurfacePublication { - current_surface_id: Option, - surface_handle: Option, -} - -#[cfg(any(test, all(feature = "hardware-render", target_os = "macos")))] -fn surface_publication_for( - published_surface_ids: &mut HashMap>, - tab_id: &str, - identity: IOSurfaceIdentity, - handle: Option, -) -> SurfacePublication { - if surface_has_been_published(published_surface_ids, tab_id, identity) { - return SurfacePublication { - current_surface_id: Some(identity.surface_id), - surface_handle: None, - }; - } - - let Some(handle) = handle.filter(|handle| handle_matches_identity(*handle, identity)) else { - return SurfacePublication { current_surface_id: None, surface_handle: None }; - }; - - published_surface_ids - .entry(tab_id.to_string()) - .or_default() - .insert(IOSurfaceIdentity::from_handle(handle)); - SurfacePublication { current_surface_id: Some(handle.surface_id), surface_handle: Some(handle) } -} - -#[cfg(any(test, all(feature = "hardware-render", target_os = "macos")))] -fn handle_matches_identity(handle: IOSurfaceHandle, identity: IOSurfaceIdentity) -> bool { - handle.surface_id == identity.surface_id - && handle.width == identity.width - && handle.height == identity.height -} - -#[cfg(any(test, all(feature = "hardware-render", target_os = "macos")))] -fn require_report_matches_surface_identity( - outcome: &LiveOutcome, - identity: IOSurfaceIdentity, -) -> Result<(), String> { - let Some(frame) = outcome.response.frame.as_ref() else { - return Ok(()); - }; - if frame.width == identity.width && frame.height == identity.height { - return Ok(()); - } - - Err(format!( - "servo hardware surface size {}x{} did not match frame report {}x{}", - identity.width, identity.height, frame.width, frame.height, - )) -} - -/// Serialise the response then stream the optional raw RGBA frame on -/// the same stdout pipe. The client reads the JSON line, takes -/// `rgba_byte_count` from the report, then reads that many bytes -/// from the same stream — no temp file round-trip. -/// -/// After the bytes hit the pipe we fold paint/encode/write/total -/// timings into the aggregator. `total_ns` is the wall-clock span -/// from `frame_started_at` (request arrival) to the stdout flush -/// returning, so it captures every per-frame cost outside the three -/// measured stages. Any summary the aggregator emits is stashed on -/// `pending_summary` and rides out on the *next* response, because -/// the protocol is one-line-per-response and an unsolicited summary -/// line would desync the main process's read loop. -pub(super) fn write_outcome( - stdout: &mut impl Write, - perf: &mut FramePerfAggregator, - pending_summary: &mut Option, - outcome: Result, - frame_started_at: Instant, -) -> Result<(), LiveSidecarError> { - let mut outcome = outcome.unwrap_or_else(|error| LiveOutcome::error(error.to_string())); - let partial_timings = outcome.partial_timings.take(); - let frame_present = outcome.response.frame.is_some(); - if let Some(summary) = pending_summary.take() { - outcome.response.perf = Some(summary); - } - // Payloadless hardware frames carry only the IOSurface selector. - let drop_rgba_payload = - outcome.response.current_surface_id.is_some() && outcome.frame.is_none(); - if drop_rgba_payload && let Some(report) = outcome.response.frame.as_mut() { - report.rgba_byte_count = 0; - } - let write_started_at = Instant::now(); - serde_json::to_writer(&mut *stdout, &outcome.response)?; - stdout.write_all(b"\n")?; - if !drop_rgba_payload && let Some(frame) = outcome.frame.as_ref() { - stdout.write_all(frame.rgba_bytes())?; - } - stdout.flush()?; - if frame_present { - let write_ns = elapsed_ns(write_started_at); - let total_ns = elapsed_ns(frame_started_at); - let partial = partial_timings.unwrap_or(PartialFrameTimings { paint_ns: 0, encode_ns: 0 }); - let timings = FrameStageTimings::from_durations( - Duration::from_nanos(partial.paint_ns), - Duration::from_nanos(partial.encode_ns), - Duration::from_nanos(write_ns), - Duration::from_nanos(total_ns), - ); - if let Some(summary) = perf.record(timings) { - *pending_summary = Some(summary); - } - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use std::{collections::HashMap, error::Error, time::Instant}; - - use ely_servo_host::{IOSurfaceHandle, IOSurfaceIdentity}; - - use super::super::{ - live_protocol::{LiveFrameReport, LiveOutcome, PartialFrameTimings}, - perf::FramePerfAggregator, - }; - use super::{require_report_matches_surface_identity, surface_publication_for, write_outcome}; - - #[test] - fn unpublished_surface_without_handle_leaves_selector_empty() { - let mut published = HashMap::new(); - let publication = - surface_publication_for(&mut published, "tab-1", identity(7, 800, 600), None); - - assert_eq!(publication.current_surface_id, None); - assert!(publication.surface_handle.is_none()); - assert!(published.is_empty()); - } - - #[test] - fn unpublished_surface_with_matching_handle_publishes_selector_and_handle() { - let mut published = HashMap::new(); - let handle = handle(7, 800, 600); - let publication = - surface_publication_for(&mut published, "tab-1", identity(7, 800, 600), Some(handle)); - - assert_eq!(publication.current_surface_id, Some(7)); - assert_eq!(publication.surface_handle, Some(handle)); - assert!(published.get("tab-1").is_some_and(|ids| ids.contains(&identity(7, 800, 600)))); - } - - #[test] - fn published_surface_reuses_selector_without_republishing_handle() { - let mut published = HashMap::new(); - let handle = handle(7, 800, 600); - let _ = - surface_publication_for(&mut published, "tab-1", identity(7, 800, 600), Some(handle)); - let publication = - surface_publication_for(&mut published, "tab-1", identity(7, 800, 600), None); - - assert_eq!(publication.current_surface_id, Some(7)); - assert!(publication.surface_handle.is_none()); - } - - #[test] - fn same_surface_id_with_changed_dimensions_republishes_handle() { - let mut published = HashMap::new(); - let initial = handle(7, 800, 600); - let resized = handle(7, 1024, 768); - - let _ = - surface_publication_for(&mut published, "tab-1", identity(7, 800, 600), Some(initial)); - let publication = - surface_publication_for(&mut published, "tab-1", identity(7, 1024, 768), Some(resized)); - - assert_eq!(publication.current_surface_id, Some(7)); - assert_eq!(publication.surface_handle, Some(resized)); - } - - #[test] - fn hardware_report_mismatch_is_reported() -> Result<(), Box> { - let outcome = LiveOutcome::from_report( - report_with_size(2180, 1586), - PartialFrameTimings { paint_ns: 1_000, encode_ns: 2_000 }, - ); - - let error = match require_report_matches_surface_identity(&outcome, identity(7, 2168, 1566)) - { - Ok(()) => return Err("mismatched IOSurface dimensions must be reported".into()), - Err(error) => error, - }; - - assert_eq!( - error, - "servo hardware surface size 2168x1566 did not match frame report 2180x1586", - ); - Ok(()) - } - - #[test] - fn mismatched_handle_leaves_surface_unpublished() { - let mut published = HashMap::new(); - let publication = surface_publication_for( - &mut published, - "tab-1", - identity(7, 800, 600), - Some(handle(8, 800, 600)), - ); - - assert_eq!(publication.current_surface_id, None); - assert!(publication.surface_handle.is_none()); - assert!(published.is_empty()); - } - - #[test] - fn payloadless_surface_report_records_perf_and_writes_no_rgba() -> Result<(), Box> { - let mut outcome = LiveOutcome::from_report( - report_with_byte_count(16), - PartialFrameTimings { paint_ns: 1_000, encode_ns: 2_000 }, - ); - outcome.response.current_surface_id = Some(7); - let mut stdout = Vec::new(); - let mut perf = FramePerfAggregator::new("hardware", 1); - let mut pending_summary = None; - - write_outcome(&mut stdout, &mut perf, &mut pending_summary, Ok(outcome), Instant::now())?; - - let Some(newline_index) = stdout.iter().position(|byte| *byte == b'\n') else { - return Err("response newline missing".into()); - }; - let line = std::str::from_utf8(&stdout[..newline_index])?; - let response: serde_json::Value = serde_json::from_str(line)?; - let rgba_byte_count = response - .get("frame") - .and_then(|frame| frame.get("rgba_byte_count")) - .and_then(serde_json::Value::as_u64); - - assert_eq!(rgba_byte_count, Some(0)); - assert!(stdout[newline_index + 1..].is_empty()); - assert!(pending_summary.is_some()); - Ok(()) - } - - fn identity(surface_id: u64, width: u32, height: u32) -> IOSurfaceIdentity { - IOSurfaceIdentity { surface_id, width, height } - } - - fn handle(surface_id: u64, width: u32, height: u32) -> IOSurfaceHandle { - IOSurfaceHandle { mach_port_name: 42, surface_id, width, height } - } - - fn report_with_byte_count(rgba_byte_count: usize) -> LiveFrameReport { - let mut report = report_with_size(2, 2); - report.rgba_byte_count = rgba_byte_count; - report - } - - fn report_with_size(width: u32, height: u32) -> LiveFrameReport { - LiveFrameReport { - loaded_url: Some("https://example.com/".to_string()), - title: Some("Example".to_string()), - state: "complete", - width, - height, - device_pixel_ratio: 1.0, - css_viewport_width: width, - css_viewport_height: height, - rgba_byte_count: 0, - non_white_pixel_count: 0, - content_pixel_count: 0, - sample_hash: 0, - } - } -} diff --git a/crates/ely_servo_host/src/bin/ely_servo_sidecar/live_protocol.rs b/crates/ely_servo_host/src/bin/ely_servo_sidecar/live_protocol.rs deleted file mode 100644 index f367c49..0000000 --- a/crates/ely_servo_host/src/bin/ely_servo_sidecar/live_protocol.rs +++ /dev/null @@ -1,300 +0,0 @@ -//! Wire types for the sidecar live loop. Split out of `live.rs` to -//! keep the hot loop and protocol surface in separate files. - -use std::io; - -use ely_servo_host::{ - IOSurfaceHandle, RenderedFrame, ServoHostError, WebViewSnapshot, WebViewState, -}; -use serde::{Deserialize, Serialize}; -use thiserror::Error; - -#[cfg(all(feature = "hardware-render", target_os = "macos"))] -use super::iosurface_mach::IOSurfaceMachError; -use super::perf::FramePerfSummary; - -#[derive(Deserialize)] -#[serde(tag = "type", rename_all = "snake_case")] -pub(super) enum LiveRequest { - Ensure { - tab_id: String, - profile_id: String, - url: String, - width: u32, - height: u32, - page_zoom_percent: u16, - /// Display scale factor reported by the host's window - /// (1.0 standard, 2.0 Retina). The sidecar plumbs this into - /// Servo's `WebView::set_hidpi_scale_factor` so CSS layout - /// happens at logical-pixel dimensions instead of physical. - /// Defaults to 1.0 for backward compatibility if a client - /// (e.g. the live perf bench) omits the field. - #[serde(default = "default_device_pixel_ratio")] - device_pixel_ratio: f32, - scroll_delta_x: i32, - scroll_delta_y: i32, - scroll_point_x: Option, - scroll_point_y: Option, - click_x: Option, - click_y: Option, - #[serde(default)] - hover_x: Option, - #[serde(default)] - hover_y: Option, - typed_text: Option, - site_permissions: Vec, - #[serde(default)] - ready_surface_ids: Vec, - }, - Poll { - tab_id: String, - #[serde(default)] - ready_surface_ids: Vec, - }, - Close { - tab_id: String, - }, -} - -fn default_device_pixel_ratio() -> f32 { - 1.0 -} - -#[derive(Deserialize)] -pub(super) struct LiveSitePermission { - pub origin: String, - pub feature: String, - pub decision: String, -} - -/// Partial stage timings captured inside `poll_frame` before the -/// write phase. Combined with the write-stage duration measured by -/// `write_outcome` to form a full set of frame timings. -#[derive(Clone, Copy, Debug)] -pub(super) struct PartialFrameTimings { - pub paint_ns: u64, - pub encode_ns: u64, -} - -/// A response plus an optional software RGBA payload and partial stage -/// timings. Software frames carry `RenderedFrame` so the write step -/// can stream its existing rgba slice straight onto the pipe; hardware -/// surface frames carry only a `LiveFrameReport`. -pub(super) struct LiveOutcome { - pub response: LiveResponse, - pub frame: Option, - pub partial_timings: Option, -} - -impl LiveOutcome { - pub fn empty() -> Self { - Self { response: LiveResponse::empty(), frame: None, partial_timings: None } - } - - pub fn error(message: String) -> Self { - Self { response: LiveResponse::error(message), frame: None, partial_timings: None } - } - - pub fn from_frame( - report: LiveFrameReport, - frame: RenderedFrame, - partial_timings: PartialFrameTimings, - ) -> Self { - Self { - response: LiveResponse::frame(report), - frame: Some(frame), - partial_timings: Some(partial_timings), - } - } - - #[cfg(any(test, all(feature = "hardware-render", target_os = "macos")))] - pub fn from_report(report: LiveFrameReport, partial_timings: PartialFrameTimings) -> Self { - Self { - response: LiveResponse::frame(report), - frame: None, - partial_timings: Some(partial_timings), - } - } -} - -#[derive(Serialize)] -pub(super) struct LiveResponse { - pub error: Option, - pub frame: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub perf: Option, - /// Populated on the first frame the sidecar emits for a given - /// surface — initial paint, after a resize, or whenever surfman - /// rotates its swap chain to a surface we haven't seen yet. The - /// receiver imports the IOSurface (via - /// `IOSurfaceLookupFromMachPort`) once per `surface_id` and caches - /// the resulting Metal texture. Always `None` on the software - /// path. - #[serde(skip_serializing_if = "Option::is_none")] - pub surface_handle: Option, - /// Populated on every hardware paint frame. Tells the receiver - /// which previously-imported IOSurface to sample THIS frame. The - /// surfman attached swap chain rotates between front/back - /// surfaces, so this id alternates between the values the receiver - /// has already imported. Always `None` on the software path. - #[serde(skip_serializing_if = "Option::is_none")] - pub current_surface_id: Option, -} - -impl LiveResponse { - fn empty() -> Self { - Self { - error: None, - frame: None, - perf: None, - surface_handle: None, - current_surface_id: None, - } - } - - fn frame(frame: LiveFrameReport) -> Self { - Self { - error: None, - frame: Some(frame), - perf: None, - surface_handle: None, - current_surface_id: None, - } - } - - fn error(message: String) -> Self { - Self { - error: Some(message), - frame: None, - perf: None, - surface_handle: None, - current_surface_id: None, - } - } -} - -#[derive(Serialize)] -pub(super) struct LiveFrameReport { - pub loaded_url: Option, - pub title: Option, - pub state: &'static str, - pub width: u32, - pub height: u32, - pub device_pixel_ratio: f32, - pub css_viewport_width: u32, - pub css_viewport_height: u32, - pub rgba_byte_count: usize, - pub non_white_pixel_count: u64, - pub content_pixel_count: u64, - pub sample_hash: u64, -} - -impl LiveFrameReport { - pub fn new(snapshot: &WebViewSnapshot, frame: &RenderedFrame, device_pixel_ratio: f32) -> Self { - let (css_viewport_width, css_viewport_height) = - css_viewport_size(frame.width(), frame.height(), device_pixel_ratio); - Self { - loaded_url: snapshot.url().map(str::to_string), - title: snapshot.title().map(str::to_string), - state: state_label(snapshot.state()), - width: frame.width(), - height: frame.height(), - device_pixel_ratio, - css_viewport_width, - css_viewport_height, - rgba_byte_count: frame.rgba_bytes().len(), - non_white_pixel_count: frame.non_white_pixel_count(), - content_pixel_count: frame.content_pixel_count(), - sample_hash: frame.sample_hash(), - } - } - - #[cfg(all(feature = "hardware-render", target_os = "macos"))] - pub fn from_surface( - snapshot: &WebViewSnapshot, - width: u32, - height: u32, - device_pixel_ratio: f32, - ) -> Self { - let (css_viewport_width, css_viewport_height) = - css_viewport_size(width, height, device_pixel_ratio); - Self { - loaded_url: snapshot.url().map(str::to_string), - title: snapshot.title().map(str::to_string), - state: state_label(snapshot.state()), - width, - height, - device_pixel_ratio, - css_viewport_width, - css_viewport_height, - rgba_byte_count: 0, - non_white_pixel_count: 0, - content_pixel_count: 0, - sample_hash: 0, - } - } -} - -fn css_viewport_size(width: u32, height: u32, device_pixel_ratio: f32) -> (u32, u32) { - let dpr = if device_pixel_ratio.is_finite() && device_pixel_ratio > 0.0 { - device_pixel_ratio - } else { - 1.0 - }; - ( - ((width as f32) / dpr).round().max(1.0) as u32, - ((height as f32) / dpr).round().max(1.0) as u32, - ) -} - -fn state_label(state: &WebViewState) -> &'static str { - match state { - WebViewState::Created => "created", - WebViewState::Loading => "loading", - WebViewState::Complete => "complete", - WebViewState::Sleeping => "sleeping", - WebViewState::Crashed => "crashed", - } -} - -#[derive(Debug, Error)] -pub(super) enum LiveSidecarError { - #[error("live session is unavailable after creation")] - SessionUnavailable, - - #[error("scroll input requires both scroll_point_x and scroll_point_y")] - IncompleteScrollPoint, - - #[error(transparent)] - Domain(#[from] ely_domain::DomainError), - - #[error(transparent)] - Host(#[from] ServoHostError), - - #[error(transparent)] - Io(#[from] io::Error), - - #[error(transparent)] - Json(#[from] serde_json::Error), - - #[cfg(all(feature = "hardware-render", target_os = "macos"))] - #[error(transparent)] - IOSurfaceMach(#[from] IOSurfaceMachError), -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn close_request_deserializes_from_wire() -> Result<(), serde_json::Error> { - let request = - serde_json::from_str::(r#"{"type":"close","tab_id":"tab-live-close"}"#)?; - - assert!(matches!( - request, - LiveRequest::Close { tab_id } if tab_id == "tab-live-close" - )); - Ok(()) - } -} diff --git a/crates/ely_servo_host/src/bin/ely_servo_sidecar/live_session.rs b/crates/ely_servo_host/src/bin/ely_servo_sidecar/live_session.rs deleted file mode 100644 index d2dfc9d..0000000 --- a/crates/ely_servo_host/src/bin/ely_servo_sidecar/live_session.rs +++ /dev/null @@ -1,240 +0,0 @@ -use std::collections::HashMap; - -use ely_domain::{DEFAULT_ZOOM_PERCENT, ProfileId, TabId}; -use ely_servo_host::{ - KeyboardTextRequest, MouseClickRequest, MouseHoverRequest, PageZoomRequest, PermissionDecision, - PermissionRequest, ResizeRequest, ScrollRequest, ServoHost, ServoSurfaceSize, - SoftwareServoHost, -}; - -use super::live_protocol::{LiveSidecarError, LiveSitePermission}; - -#[derive(Clone)] -pub(super) struct LiveSession { - pub(super) webview_id: ely_domain::WebViewId, - pub(super) requested_url: String, - pub(super) width: u32, - pub(super) height: u32, - page_zoom_percent: u16, - /// Last hidpi factor pushed to Servo, encoded as `(scale × 1000)`. - /// Stored as a u32 so equality is cheap and stable across the - /// f32 jitter that JSON parsing can introduce. Init to 0 so the - /// first apply_layout call always pushes a real value. - hidpi_scale_milli: u32, - pub(super) scroll_x: i32, - pub(super) scroll_y: i32, - pub(super) awaiting_visible_frame: bool, - /// Sticky for the lifetime of a single URL: flipped to `true` - /// the first time `poll_frame` sees a paint with real content - /// (non-white, non-empty) and reset to `false` on every navigate. - /// After it's `true`, the visible-content gate stops gating: - /// scroll/click/hover/type all return on the first - /// `has_pending_frame=true` (~3 ms) instead of waiting the full - /// `LIVE_FRAME_WAIT_TIMEOUT`. The gate stays armed for the - /// initial paint of each new URL so loading frames are still - /// skipped. - pub(super) ever_visible_frame: bool, -} - -impl LiveSession { - fn new(webview_id: ely_domain::WebViewId, _width: u32, _height: u32) -> Self { - Self { - webview_id, - requested_url: String::new(), - width: 0, - height: 0, - page_zoom_percent: DEFAULT_ZOOM_PERCENT, - hidpi_scale_milli: 0, - scroll_x: 0, - scroll_y: 0, - awaiting_visible_frame: false, - ever_visible_frame: false, - } - } - - pub(super) fn device_pixel_ratio(&self) -> f32 { - hidpi_scale_milli_to_f32(self.hidpi_scale_milli) - } -} - -pub(super) fn ensure_session<'a>( - host: &mut SoftwareServoHost, - sessions: &'a mut HashMap, - key: String, - tab_id: &TabId, - profile_id: &ProfileId, - width: u32, - height: u32, -) -> Result<&'a mut LiveSession, LiveSidecarError> { - if !sessions.contains_key(&key) { - let webview_id = host.create_webview_with_size( - tab_id.clone(), - profile_id.clone(), - ServoSurfaceSize::new(width, height), - )?; - sessions.insert(key.clone(), LiveSession::new(webview_id, width, height)); - } - - sessions.get_mut(&key).ok_or(LiveSidecarError::SessionUnavailable) -} - -pub(super) fn apply_layout( - host: &mut SoftwareServoHost, - session: &mut LiveSession, - width: u32, - height: u32, - page_zoom_percent: u16, - device_pixel_ratio: f32, -) -> Result { - let mut changed = false; - // Push the device pixel ratio BEFORE resize. Servo's WebView - // defaults hidpi to 1.0; without this the first layout treats - // physical-pixel viewport widths as CSS-pixel widths and the page - // lays out half the size you'd expect on a Retina display. - let hidpi_scale_milli = encode_hidpi_scale_milli(device_pixel_ratio); - if session.hidpi_scale_milli != hidpi_scale_milli { - host.set_hidpi_scale(ely_servo_host::HidpiScaleRequest { - webview_id: session.webview_id.clone(), - scale_factor: hidpi_scale_milli_to_f32(hidpi_scale_milli), - })?; - session.hidpi_scale_milli = hidpi_scale_milli; - changed = true; - } - - if session.width != width || session.height != height { - host.resize(ResizeRequest { webview_id: session.webview_id.clone(), width, height })?; - session.width = width; - session.height = height; - changed = true; - } - - if session.page_zoom_percent != page_zoom_percent { - host.set_page_zoom(PageZoomRequest { - webview_id: session.webview_id.clone(), - zoom_factor: f32::from(page_zoom_percent) / 100.0, - })?; - session.page_zoom_percent = page_zoom_percent; - changed = true; - } - - Ok(changed) -} - -pub(super) fn apply_permissions( - host: &mut SoftwareServoHost, - session: &LiveSession, - profile_id: &ProfileId, - permissions: Vec, -) -> Result<(), LiveSidecarError> { - for permission in permissions { - host.set_permission( - PermissionRequest { - webview_id: session.webview_id.clone(), - profile_id: profile_id.clone(), - origin: ely_domain::SiteOrigin::parse(permission.origin)?, - feature: ely_domain::SitePermissionFeature::parse(permission.feature.as_str())?, - }, - PermissionDecision::from(ely_domain::SitePermissionDecision::parse( - permission.decision.as_str(), - )?), - )?; - } - - Ok(()) -} - -pub(super) fn apply_input( - host: &mut SoftwareServoHost, - session: &mut LiveSession, - input: LiveInput, -) -> Result { - let mut changed = false; - if input.scroll_delta_x != 0 || input.scroll_delta_y != 0 { - let (point_x, point_y) = input.scroll_point()?; - host.scroll(ScrollRequest { - webview_id: session.webview_id.clone(), - delta_x: input.scroll_delta_x, - delta_y: input.scroll_delta_y, - point_x, - point_y, - })?; - session.scroll_x = positive_scroll_component(session.scroll_x, input.scroll_delta_x); - session.scroll_y = positive_scroll_component(session.scroll_y, input.scroll_delta_y); - changed = true; - } - - if let (Some(x), Some(y)) = (input.hover_x, input.hover_y) { - host.hover(MouseHoverRequest { webview_id: session.webview_id.clone(), x, y })?; - changed = true; - } - - if let (Some(x), Some(y)) = (input.click_x, input.click_y) { - host.click(MouseClickRequest { webview_id: session.webview_id.clone(), x, y })?; - changed = true; - } - - if let Some(text) = input.typed_text { - host.type_text(KeyboardTextRequest { webview_id: session.webview_id.clone(), text })?; - changed = true; - } - - Ok(changed) -} - -pub(super) struct LiveInput { - pub(super) scroll_delta_x: i32, - pub(super) scroll_delta_y: i32, - pub(super) scroll_point_x: Option, - pub(super) scroll_point_y: Option, - pub(super) click_x: Option, - pub(super) click_y: Option, - pub(super) hover_x: Option, - pub(super) hover_y: Option, - pub(super) typed_text: Option, -} - -impl LiveInput { - fn scroll_point(&self) -> Result<(u32, u32), LiveSidecarError> { - let point = match (self.scroll_point_x, self.scroll_point_y) { - (Some(x), Some(y)) => (x, y), - _ => return Err(LiveSidecarError::IncompleteScrollPoint), - }; - Ok(point) - } -} - -fn encode_hidpi_scale_milli(scale: f32) -> u32 { - if !scale.is_finite() || scale <= 0.0 { - return 1_000; - } - let scaled = (scale * 1_000.0).round(); - scaled.clamp(500.0, 5_000.0) as u32 -} - -fn hidpi_scale_milli_to_f32(milli: u32) -> f32 { - milli as f32 / 1_000.0 -} - -fn positive_scroll_component(current: i32, delta: i32) -> i32 { - let value = i64::from(current) + i64::from(delta); - value.clamp(0, i64::from(i32::MAX)) as i32 -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn new_live_session_forces_first_resize_after_hidpi() { - let session = LiveSession::new(ely_domain::WebViewId::new(), 1280, 720); - - assert_ne!( - session.width, 1280, - "first apply_layout must resize after hidpi has been pushed", - ); - assert_ne!( - session.height, 720, - "first apply_layout must resize after hidpi has been pushed", - ); - } -} diff --git a/crates/ely_servo_host/src/bin/ely_servo_sidecar/perf.rs b/crates/ely_servo_host/src/bin/ely_servo_sidecar/perf.rs deleted file mode 100644 index a570e9c..0000000 --- a/crates/ely_servo_host/src/bin/ely_servo_sidecar/perf.rs +++ /dev/null @@ -1,257 +0,0 @@ -//! Per-frame paint→encode→write stage timings for the live sidecar -//! loop, plus exact fixed-window percentile summaries so the main -//! process can read out p50/p95/p99 latencies. -//! -//! Why this lives next to `live.rs`: the sidecar already owns the hot -//! loop. Sampling here costs one `Instant::now()` per stage boundary. -//! Each stage keeps one preallocated window of nanosecond samples and -//! sorts only at the 60-frame summary boundary, so steady-state record -//! cost stays a single push per stage while p95 remains exact enough -//! for 120 fps gates. - -use std::time::{Duration, Instant}; - -/// Per-frame stage timings captured by the live loop. -/// -/// `total_ns` is the real wall-clock span from request arrival to the -/// stdout flush returning, so it captures every byte of overhead -/// outside paint/encode/write (snapshot reads, JSON parse, scratch -/// allocations). It is measured at the loop boundary, not summed. -#[derive(Clone, Copy, Debug)] -pub(super) struct FrameStageTimings { - pub paint_ns: u64, - pub encode_ns: u64, - pub write_ns: u64, - pub total_ns: u64, -} - -impl FrameStageTimings { - pub(super) fn from_durations( - paint: Duration, - encode: Duration, - write: Duration, - total: Duration, - ) -> Self { - Self { - paint_ns: duration_to_ns(paint), - encode_ns: duration_to_ns(encode), - write_ns: duration_to_ns(write), - total_ns: duration_to_ns(total), - } - } -} - -fn duration_to_ns(duration: Duration) -> u64 { - u64::try_from(duration.as_nanos()).unwrap_or(u64::MAX) -} - -/// Saturating elapsed-ns helper. `Instant::elapsed` is monotonic but -/// the cast can still overflow on the (impossible) hour-long frame. -pub(super) fn elapsed_ns(start: Instant) -> u64 { - duration_to_ns(start.elapsed()) -} - -#[derive(Debug)] -struct StageSamples { - values: Vec, -} - -impl StageSamples { - fn new(window_size: usize) -> Self { - Self { values: Vec::with_capacity(window_size) } - } - - fn record(&mut self, ns: u64) { - self.values.push(ns); - } - - fn len(&self) -> usize { - self.values.len() - } - - fn percentiles_us(&self) -> StagePercentiles { - let mut sorted = self.values.clone(); - sorted.sort_unstable(); - StagePercentiles { - p50: percentile_us(&sorted, 0.50), - p95: percentile_us(&sorted, 0.95), - p99: percentile_us(&sorted, 0.99), - } - } - - fn reset(&mut self) { - self.values.clear(); - } -} - -#[derive(Clone, Copy)] -struct StagePercentiles { - p50: u64, - p95: u64, - p99: u64, -} - -fn percentile_us(sorted_ns: &[u64], percentile: f64) -> u64 { - if sorted_ns.is_empty() { - return 0; - } - let target = ((sorted_ns.len() as f64) * percentile).ceil() as usize; - let index = target.max(1).min(sorted_ns.len()) - 1; - ns_to_us_ceil(sorted_ns[index]) -} - -fn ns_to_us_ceil(ns: u64) -> u64 { - ns.div_ceil(1_000) -} - -/// Aggregates a rolling window of [`FrameStageTimings`] across N -/// frames, exposing one [`FramePerfSummary`] per window flush. -pub(super) struct FramePerfAggregator { - window_size: usize, - paint: StageSamples, - encode: StageSamples, - write: StageSamples, - total: StageSamples, - context_label: &'static str, -} - -impl FramePerfAggregator { - pub(super) const DEFAULT_WINDOW_SIZE: u32 = 60; - - pub(super) fn new(context_label: &'static str, window_size: u32) -> Self { - let window_size = usize::try_from(window_size.max(1)).unwrap_or(usize::MAX); - Self { - window_size, - paint: StageSamples::new(window_size), - encode: StageSamples::new(window_size), - write: StageSamples::new(window_size), - total: StageSamples::new(window_size), - context_label, - } - } - - pub(super) fn record(&mut self, timings: FrameStageTimings) -> Option { - self.paint.record(timings.paint_ns); - self.encode.record(timings.encode_ns); - self.write.record(timings.write_ns); - self.total.record(timings.total_ns); - if self.paint.len() < self.window_size { - return None; - } - let paint = self.paint.percentiles_us(); - let encode = self.encode.percentiles_us(); - let write = self.write.percentiles_us(); - let total = self.total.percentiles_us(); - let summary = FramePerfSummary { - window: u32::try_from(self.paint.len()).unwrap_or(u32::MAX), - context: self.context_label, - paint_p50_us: paint.p50, - paint_p95_us: paint.p95, - paint_p99_us: paint.p99, - encode_p50_us: encode.p50, - encode_p95_us: encode.p95, - encode_p99_us: encode.p99, - write_p50_us: write.p50, - write_p95_us: write.p95, - write_p99_us: write.p99, - total_p50_us: total.p50, - total_p95_us: total.p95, - total_p99_us: total.p99, - }; - self.paint.reset(); - self.encode.reset(); - self.write.reset(); - self.total.reset(); - Some(summary) - } -} - -#[derive(Clone, Copy, Debug, serde::Serialize)] -pub(super) struct FramePerfSummary { - pub window: u32, - pub context: &'static str, - pub paint_p50_us: u64, - pub paint_p95_us: u64, - pub paint_p99_us: u64, - pub encode_p50_us: u64, - pub encode_p95_us: u64, - pub encode_p99_us: u64, - pub write_p50_us: u64, - pub write_p95_us: u64, - pub write_p99_us: u64, - pub total_p50_us: u64, - pub total_p95_us: u64, - pub total_p99_us: u64, -} - -#[cfg(test)] -mod tests { - use super::{FramePerfAggregator, FrameStageTimings, percentile_us}; - use std::time::Duration; - - #[test] - fn percentile_us_uses_nearest_rank_and_ceils_microseconds() { - let sorted_ns = [1, 1_000, 1_001]; - assert_eq!(percentile_us(&sorted_ns, 0.50), 1); - assert_eq!(percentile_us(&sorted_ns, 0.95), 2); - assert_eq!(percentile_us(&sorted_ns, 0.99), 2); - } - - #[test] - fn aggregator_emits_summary_after_window_size_records() -> Result<(), &'static str> { - let mut aggregator = - FramePerfAggregator::new("software", FramePerfAggregator::DEFAULT_WINDOW_SIZE); - for index in 0..(FramePerfAggregator::DEFAULT_WINDOW_SIZE - 1) { - let result = aggregator.record(constant_timing()); - assert!(result.is_none(), "should not flush at frame {index}"); - } - let summary = aggregator - .record(constant_timing()) - .ok_or("aggregator must flush at window boundary")?; - assert_eq!(summary.window, FramePerfAggregator::DEFAULT_WINDOW_SIZE); - assert_eq!(summary.context, "software"); - Ok(()) - } - - #[test] - fn aggregator_resets_after_flush_so_next_window_starts_fresh() { - let mut aggregator = FramePerfAggregator::new("hardware", 2); - let _ = aggregator.record(constant_timing()); - let summary = aggregator.record(constant_timing()); - assert!(summary.is_some(), "expected first flush"); - let after_flush = aggregator.record(constant_timing()); - assert!(after_flush.is_none(), "aggregator must zero counters after flush"); - } - - #[test] - fn aggregator_percentiles_track_increasing_paint_durations() -> Result<(), &'static str> { - let mut aggregator = FramePerfAggregator::new("software", 4); - let paint_durations_us = [10u64, 100, 1_000, 10_000]; - let mut summary = None; - for paint_us in paint_durations_us { - summary = aggregator.record(FrameStageTimings::from_durations( - Duration::from_micros(paint_us), - Duration::from_micros(1), - Duration::from_micros(1), - Duration::from_micros(paint_us + 2), - )); - } - let summary = summary.ok_or("4-frame window must flush")?; - assert_eq!(summary.paint_p50_us, 100); - assert_eq!(summary.paint_p95_us, 10_000); - assert_eq!(summary.paint_p99_us, 10_000); - assert_eq!(summary.total_p50_us, 102); - assert_eq!(summary.total_p95_us, 10_002); - assert_eq!(summary.total_p99_us, 10_002); - Ok(()) - } - - fn constant_timing() -> FrameStageTimings { - FrameStageTimings::from_durations( - Duration::from_micros(2_000), - Duration::from_micros(500), - Duration::from_micros(100), - Duration::from_micros(2_600), - ) - } -} diff --git a/crates/ely_servo_host/src/bin/ely_servo_sidecar/report.rs b/crates/ely_servo_host/src/bin/ely_servo_sidecar/report.rs deleted file mode 100644 index 9584d7b..0000000 --- a/crates/ely_servo_host/src/bin/ely_servo_sidecar/report.rs +++ /dev/null @@ -1,98 +0,0 @@ -use ely_servo_host::{RenderedFrame, WebViewSnapshot, WebViewState}; -use serde::Serialize; - -use super::args::SnapshotArgs; - -pub(super) struct SnapshotInputChanges { - pub(super) scroll: bool, - pub(super) click: bool, - pub(super) drag: bool, - pub(super) touch: bool, - pub(super) text: bool, -} - -#[derive(Serialize)] -pub(super) struct SnapshotReport { - requested_url: String, - profile_id: String, - loaded_url: Option, - title: Option, - rgba_path: String, - state: &'static str, - width: u32, - height: u32, - rgba_byte_count: usize, - opaque_pixel_count: u64, - non_white_pixel_count: u64, - content_pixel_count: u64, - sample_hash: u64, - scroll_x: i32, - scroll_y: i32, - page_zoom_percent: u16, - scroll_changed_frame: bool, - click_x: Option, - click_y: Option, - click_changed_frame: bool, - drag_from_x: Option, - drag_from_y: Option, - drag_to_x: Option, - drag_to_y: Option, - drag_changed_frame: bool, - touch_x: Option, - touch_y: Option, - touch_changed_frame: bool, - typed_text_byte_count: usize, - text_changed_frame: bool, -} - -impl SnapshotReport { - pub(super) fn new( - args: &SnapshotArgs, - snapshot: &WebViewSnapshot, - frame: &RenderedFrame, - changes: SnapshotInputChanges, - ) -> Self { - Self { - requested_url: args.url.as_str().to_string(), - profile_id: snapshot.profile_id().as_str().to_string(), - loaded_url: snapshot.url().map(str::to_string), - title: snapshot.title().map(str::to_string), - rgba_path: args.rgba_out.display().to_string(), - state: state_label(snapshot.state()), - width: frame.width(), - height: frame.height(), - rgba_byte_count: frame.rgba_bytes().len(), - opaque_pixel_count: frame.opaque_pixel_count(), - non_white_pixel_count: frame.non_white_pixel_count(), - content_pixel_count: frame.content_pixel_count(), - sample_hash: frame.sample_hash(), - scroll_x: args.scroll_x, - scroll_y: args.scroll_y, - page_zoom_percent: args.page_zoom_percent, - scroll_changed_frame: changes.scroll, - click_x: args.click_point.map(|point| point.x), - click_y: args.click_point.map(|point| point.y), - click_changed_frame: changes.click, - drag_from_x: args.drag_points.map(|points| points.from.x), - drag_from_y: args.drag_points.map(|points| points.from.y), - drag_to_x: args.drag_points.map(|points| points.to.x), - drag_to_y: args.drag_points.map(|points| points.to.y), - drag_changed_frame: changes.drag, - touch_x: args.touch_point.map(|point| point.x), - touch_y: args.touch_point.map(|point| point.y), - touch_changed_frame: changes.touch, - typed_text_byte_count: args.typed_text.as_ref().map_or(0, String::len), - text_changed_frame: changes.text, - } - } -} - -fn state_label(state: &WebViewState) -> &'static str { - match state { - WebViewState::Created => "created", - WebViewState::Loading => "loading", - WebViewState::Complete => "complete", - WebViewState::Sleeping => "sleeping", - WebViewState::Crashed => "crashed", - } -} diff --git a/crates/ely_servo_host/src/error.rs b/crates/ely_servo_host/src/error.rs index 701e38b..67b2dac 100644 --- a/crates/ely_servo_host/src/error.rs +++ b/crates/ely_servo_host/src/error.rs @@ -24,21 +24,6 @@ pub enum ServoHostError { #[error("servo rendering context could not be made current")] RenderingContextNotCurrent, - #[error( - "hardware rendering context requested but the `hardware-render` feature \ - was not compiled in; rebuild with --features servo-engine,hardware-render" - )] - HardwareRenderUnavailable, - #[error("servo rendered frame is unavailable")] RenderedFrameUnavailable, - - #[error("servo hardware surface is unavailable for {id}")] - HardwareSurfaceUnavailable { id: WebViewId }, - - #[error("servo screenshot capture timed out for {id}")] - ScreenshotTimedOut { id: WebViewId }, - - #[error("servo screenshot capture failed: {reason}")] - ScreenshotUnavailable { reason: String }, } diff --git a/crates/ely_servo_host/src/hardware_rendering_context.rs b/crates/ely_servo_host/src/hardware_rendering_context.rs deleted file mode 100644 index 80952a9..0000000 --- a/crates/ely_servo_host/src/hardware_rendering_context.rs +++ /dev/null @@ -1,398 +0,0 @@ -//! Headless hardware [`RenderingContext`] for Servo, vendored from -//! `servo-paint-api`'s private `SurfmanRenderingContext` and reshaped -//! so it can be constructed without a `RawWindowHandle`. -//! -//! Why this file exists: `servo-paint-api 0.1` exposes three -//! constructors — `SoftwareRenderingContext` (CPU-only), -//! `WindowRenderingContext` (requires `DisplayHandle + WindowHandle`), -//! and `OffscreenRenderingContext` (must be a child of a -//! `WindowRenderingContext`). The sidecar process has no window, so -//! none of the three works for us when we want **hardware** -//! rasterising. The underlying `SurfmanRenderingContext` glue *can* -//! drive a hardware adapter against a `SurfaceType::Generic` -//! offscreen surface — that's exactly what we need — but its -//! constructor is `fn new` (private). Until Servo accepts an upstream -//! PR exposing a headless hardware constructor, this file vendors the -//! minimal slice of glue we need. -//! -//! Scope kept deliberately narrow: -//! -//! * `prepare_for_rendering`, `read_to_image`, `size`, `resize`, -//! `present`, `make_current`, `gleam_gl_api`, `glow_gl_api`, and -//! `connection` are vendored. `connection` is mandatory: -//! `servo-paint`'s painter calls `rendering_context.connection() -//! .expect("Failed to get connection")` while constructing its -//! painter, so a `None` default panics the compositor before the -//! first frame is ever painted. -//! * `create_texture`/`destroy_texture` still fall through to the -//! trait defaults — Servo only reaches for them when sharing -//! surfman surfaces with its compositor for WebGL/WebGPU, which -//! this readback path does not exercise. -//! * No `RefreshDriver`. The sidecar drives its own polling loop. -//! * The reading path inlines `read_framebuffer_to_image` from the -//! same upstream file so we don't take a dependency on a private -//! helper that may change shape. -//! -//! This is feature-gated on `hardware-render`. The default build path -//! (and every existing test in this repo) keeps using -//! `SoftwareRenderingContext`; the hardware constructor only exists -//! when the feature is enabled, which is also when the additional -//! surfman/gleam/glow deps are pulled in. - -#![cfg(feature = "hardware-render")] - -use std::cell::{Cell, RefCell}; -use std::rc::Rc; -use std::sync::Arc; - -use dpi::PhysicalSize; -use euclid::Size2D; -use gleam::gl::{self, Gl}; -use image::RgbaImage; -use servo::{DeviceIntRect, RenderingContext}; -use surfman::chains::{PreserveBuffer, SwapChain, SwapChainAPI}; -#[cfg(target_os = "macos")] -use surfman::platform::macos::cgl::surface::NativeSurface; -use surfman::{ - Connection, Context, ContextAttributeFlags, ContextAttributes, Device, Error as SurfmanError, - GLApi, NativeWidget, Surface, SurfaceAccess, SurfaceType, -}; - -/// A headless hardware-backed [`RenderingContext`]. -/// -/// Construct with [`HardwareOffscreenContext::new`]; drop normally to -/// release the surfman context, surface, and swap chain. -pub struct HardwareOffscreenContext { - size: Cell>, - inner: SurfmanInner, - swap_chain: SwapChain, - #[cfg(target_os = "macos")] - held_presented_surface: RefCell>, - #[cfg(target_os = "macos")] - last_presented_iosurface: RefCell>, -} - -impl HardwareOffscreenContext { - /// Build a new hardware context with an offscreen - /// [`SurfaceType::Generic`] surface of the requested size. - /// - /// Uses `Connection::new()` to pick the platform default - /// (CGL on macOS — which backs surfaces with `IOSurface`s — - /// EGL on Linux, WGL on Windows) and `create_adapter()` for the - /// real GPU adapter. Falls back nowhere: if the host can't give - /// us a hardware GL context, the returned `Err` carries the - /// surfman cause and the caller is expected to either retry with - /// the software path or surface the failure. - pub fn new(size: PhysicalSize) -> Result { - let connection = Connection::new()?; - let adapter = connection.create_adapter()?; - let inner = SurfmanInner::new(&connection, &adapter)?; - let surfman_size = Size2D::new(size.width as i32, size.height as i32); - let surface = inner.create_surface(SurfaceType::Generic { size: surfman_size })?; - inner.bind_surface(surface)?; - inner.make_current()?; - let swap_chain = inner.create_attached_swap_chain()?; - Ok(Self { - size: Cell::new(size), - inner, - swap_chain, - #[cfg(target_os = "macos")] - held_presented_surface: RefCell::new(None), - #[cfg(target_os = "macos")] - last_presented_iosurface: RefCell::new(None), - }) - } -} - -impl Drop for HardwareOffscreenContext { - fn drop(&mut self) { - let device = &mut self.inner.device.borrow_mut(); - let context = &mut self.inner.context.borrow_mut(); - #[cfg(target_os = "macos")] - self.destroy_held_presented_surface(device, context); - let _ = self.swap_chain.destroy(device, context); - } -} - -impl RenderingContext for HardwareOffscreenContext { - fn prepare_for_rendering(&self) { - self.inner.prepare_for_rendering(); - } - - fn read_to_image(&self, source_rectangle: DeviceIntRect) -> Option { - self.inner.read_to_image(source_rectangle) - } - - fn size(&self) -> PhysicalSize { - self.size.get() - } - - fn resize(&self, size: PhysicalSize) { - if self.size.get() == size { - return; - } - - self.size.set(size); - - let device = &mut self.inner.device.borrow_mut(); - let context = &mut self.inner.context.borrow_mut(); - #[cfg(target_os = "macos")] - self.destroy_held_presented_surface(device, context); - let size = Size2D::new(size.width as i32, size.height as i32); - let _ = self.swap_chain.resize(device, context, size); - } - - fn present(&self) { - let device = &mut self.inner.device.borrow_mut(); - let context = &mut self.inner.context.borrow_mut(); - #[cfg(target_os = "macos")] - self.recycle_held_presented_surface(); - let _ = self.swap_chain.swap_buffers(device, context, PreserveBuffer::No); - #[cfg(target_os = "macos")] - self.capture_presented_iosurface(device); - } - - fn make_current(&self) -> Result<(), SurfmanError> { - self.inner.make_current() - } - - fn gleam_gl_api(&self) -> Rc { - self.inner.gleam_gl.clone() - } - - fn glow_gl_api(&self) -> Arc { - self.inner.glow_gl.clone() - } - - fn connection(&self) -> Option { - Some(self.inner.device.borrow().connection()) - } -} - -#[cfg(target_os = "macos")] -use crate::iosurface_handle::{IOSurfaceHandle, IOSurfaceIdentity}; - -#[cfg(target_os = "macos")] -impl HardwareOffscreenContext { - /// Cheap, non-mutating identity probe of the IOSurface that was - /// just presented. Used by the sidecar to dedup mach port creation. - pub fn peek_iosurface_identity(&self) -> Result, SurfmanError> { - Ok(self.last_presented_iosurface.borrow().as_ref().map(|surface| surface.identity)) - } - - /// Snapshot the just-presented IOSurface and return its mach port - /// name plus dimensions and stable surface id. Increments the - /// IOSurface's mach-port use count; the - /// receiving process holds it via `IOSurfaceLookupFromMachPort` and - /// is responsible for `mach_port_deallocate` once the import is - /// finished. - pub fn current_iosurface_mach_port(&self) -> Result { - let presented = self.last_presented_iosurface.borrow(); - let presented = presented.as_ref().ok_or(SurfmanError::Failed)?; - let mach_port = presented.native.0.create_mach_port(); - Ok(IOSurfaceHandle { - mach_port_name: mach_port, - surface_id: presented.identity.surface_id, - width: presented.identity.width, - height: presented.identity.height, - }) - } - - fn capture_presented_iosurface(&self, device: &mut Device) { - let Some(surface) = self.swap_chain.take_pending_surface() else { - self.last_presented_iosurface.borrow_mut().take(); - return; - }; - let info = device.surface_info(&surface); - let native = device.native_surface(&surface); - let identity = IOSurfaceIdentity { - surface_id: info.id.0 as u64, - width: u32::try_from(info.size.width).unwrap_or(0), - height: u32::try_from(info.size.height).unwrap_or(0), - }; - self.held_presented_surface.replace(Some(surface)); - self.last_presented_iosurface.replace(Some(PresentedIOSurface { identity, native })); - } - - fn recycle_held_presented_surface(&self) { - if let Some(surface) = self.held_presented_surface.borrow_mut().take() { - self.swap_chain.recycle_surface(surface); - } - } - - fn destroy_held_presented_surface(&self, device: &mut Device, context: &mut Context) { - self.last_presented_iosurface.borrow_mut().take(); - if let Some(mut surface) = self.held_presented_surface.borrow_mut().take() { - let _ = device.destroy_surface(context, &mut surface); - } - } -} - -#[cfg(target_os = "macos")] -struct PresentedIOSurface { - identity: IOSurfaceIdentity, - native: NativeSurface, -} - -/// Trimmed mirror of `paint_api::rendering_context::SurfmanRenderingContext`. -/// -/// Only the methods the public type above actually uses are kept; the -/// upstream original also wires up texture sharing, refresh drivers, -/// and several other knobs that Servo's compositor reaches into but -/// the embedder's headless readback path does not. -struct SurfmanInner { - gleam_gl: Rc, - glow_gl: Arc, - device: RefCell, - context: RefCell, -} - -impl Drop for SurfmanInner { - fn drop(&mut self) { - let device = &mut self.device.borrow_mut(); - let context = &mut self.context.borrow_mut(); - let _ = device.destroy_context(context); - } -} - -impl SurfmanInner { - fn new(connection: &Connection, adapter: &surfman::Adapter) -> Result { - let device = connection.create_device(adapter)?; - - let flags = ContextAttributeFlags::ALPHA - | ContextAttributeFlags::DEPTH - | ContextAttributeFlags::STENCIL; - let gl_api = connection.gl_api(); - let version = match &gl_api { - GLApi::GLES => surfman::GLVersion { major: 3, minor: 0 }, - GLApi::GL => surfman::GLVersion { major: 3, minor: 2 }, - }; - let context_descriptor = - device.create_context_descriptor(&ContextAttributes { flags, version })?; - let context = device.create_context(&context_descriptor, None)?; - - // Loading the GL function pointers requires unsafe ABI calls - // through surfman's `get_proc_address` — these are the same - // calls the upstream `SurfmanRenderingContext::new` makes, - // and they're sound for the same reason: surfman guarantees - // the returned function pointers match the requested API. - #[expect(unsafe_code)] - let gleam_gl = { - match gl_api { - GLApi::GL => unsafe { - gl::GlFns::load_with(|name| device.get_proc_address(&context, name)) - }, - GLApi::GLES => unsafe { - gl::GlesFns::load_with(|name| device.get_proc_address(&context, name)) - }, - } - }; - - #[expect(unsafe_code)] - let glow_gl = unsafe { - glow::Context::from_loader_function(|name| device.get_proc_address(&context, name)) - }; - - Ok(Self { - gleam_gl, - glow_gl: Arc::new(glow_gl), - device: RefCell::new(device), - context: RefCell::new(context), - }) - } - - fn create_surface( - &self, - surface_type: SurfaceType, - ) -> Result { - let device = &mut self.device.borrow_mut(); - let context = &self.context.borrow(); - device.create_surface(context, SurfaceAccess::GPUOnly, surface_type) - } - - fn bind_surface(&self, surface: Surface) -> Result<(), SurfmanError> { - let device = &self.device.borrow(); - let context = &mut self.context.borrow_mut(); - device.bind_surface_to_context(context, surface).map_err(|(err, mut surface)| { - let _ = device.destroy_surface(context, &mut surface); - err - })?; - Ok(()) - } - - fn create_attached_swap_chain(&self) -> Result, SurfmanError> { - let device = &mut self.device.borrow_mut(); - let context = &mut self.context.borrow_mut(); - SwapChain::create_attached(device, context, SurfaceAccess::GPUOnly) - } - - fn make_current(&self) -> Result<(), SurfmanError> { - let device = &self.device.borrow(); - let context = &self.context.borrow(); - device.make_context_current(context) - } - - fn framebuffer_id(&self) -> u32 { - let device = &self.device.borrow(); - let context = &self.context.borrow(); - device - .context_surface_info(context) - .unwrap_or(None) - .and_then(|info| info.framebuffer_object) - .map_or(0, |framebuffer| framebuffer.0.into()) - } - - fn prepare_for_rendering(&self) { - let framebuffer_id = self.framebuffer_id(); - self.gleam_gl.bind_framebuffer(gleam::gl::FRAMEBUFFER, framebuffer_id); - } - - /// Inlined copy of `Framebuffer::read_framebuffer_to_image` from - /// `paint-api`. Reads the bound framebuffer into a `Vec`, - /// flips it vertically (GL's origin is bottom-left, the rest of - /// the embedder expects top-left), and returns it as an - /// [`RgbaImage`]. Returns `None` if `RgbaImage::from_raw` rejects - /// the buffer (size mismatch); GL errors are logged but don't - /// abort the read — the caller can decide whether a corrupt - /// frame is recoverable. - fn read_to_image(&self, source_rectangle: DeviceIntRect) -> Option { - let framebuffer_id = self.framebuffer_id(); - self.gleam_gl.bind_framebuffer(gl::FRAMEBUFFER, framebuffer_id); - // Working around an OSMesa headless bug carried forward from - // the upstream implementation, see servo/servo#18606. - self.gleam_gl.bind_vertex_array(0); - - let mut pixels = self.gleam_gl.read_pixels( - source_rectangle.min.x, - source_rectangle.min.y, - source_rectangle.width(), - source_rectangle.height(), - gl::RGBA, - gl::UNSIGNED_BYTE, - ); - let gl_error = self.gleam_gl.get_error(); - if gl_error != gl::NO_ERROR { - log::warn!("GL error 0x{gl_error:x} after read_pixels in hardware offscreen context"); - } - - let source_rectangle = source_rectangle.to_usize(); - let stride = source_rectangle.width().checked_mul(4)?; - let mirror = pixels.clone(); - for y in 0..source_rectangle.height() { - let dst_start = y.checked_mul(stride)?; - let src_start = (source_rectangle.height().checked_sub(y + 1)?).checked_mul(stride)?; - let dst_end = dst_start.checked_add(stride)?; - let src_end = src_start.checked_add(stride)?; - if dst_end > pixels.len() || src_end > mirror.len() { - return None; - } - pixels[dst_start..dst_end].clone_from_slice(&mirror[src_start..src_end]); - } - - RgbaImage::from_raw( - source_rectangle.width() as u32, - source_rectangle.height() as u32, - pixels, - ) - } -} diff --git a/crates/ely_servo_host/src/host.rs b/crates/ely_servo_host/src/host.rs index 6878213..11442c8 100644 --- a/crates/ely_servo_host/src/host.rs +++ b/crates/ely_servo_host/src/host.rs @@ -284,11 +284,6 @@ pub struct KeyboardTextRequest { pub text: String, } -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct ScreenshotRequest { - pub webview_id: WebViewId, -} - #[derive(Clone, Debug, Eq, PartialEq)] pub struct PermissionRequest { pub webview_id: WebViewId, @@ -341,11 +336,6 @@ pub trait ServoHost { fn type_text(&mut self, request: KeyboardTextRequest) -> Result<(), ServoHostError>; - fn capture_screenshot( - &mut self, - request: ScreenshotRequest, - ) -> Result; - fn set_permission( &mut self, request: PermissionRequest, diff --git a/crates/ely_servo_host/src/iosurface_handle.rs b/crates/ely_servo_host/src/iosurface_handle.rs deleted file mode 100644 index c6647fa..0000000 --- a/crates/ely_servo_host/src/iosurface_handle.rs +++ /dev/null @@ -1,48 +0,0 @@ -//! Cross-process IOSurface descriptor types. -//! -//! These wire types live outside `hardware_rendering_context` (which -//! is hardware-render + macOS gated) so the sidecar's JSON protocol -//! can carry an `Option` regardless of feature -//! flags. The receiver always knows how to parse the field; if no -//! sender ever populates it (software-only build), it's just `None` -//! on every frame. -//! -//! Minting an [`IOSurfaceHandle`] requires a hardware surfman context -//! and a macOS host. That part lives in -//! [`crate::hardware_rendering_context`]. - -/// Cross-process handle to a hardware surface: the receiving process -/// rebuilds an `IOSurfaceRef` from `mach_port_name` and imports it as -/// a Metal texture without copying pixels. -/// -/// `surface_id` is the stable surfman `SurfaceID` (a pointer-shaped -/// `usize` widened to `u64` for the wire). Together with `width` and -/// `height` it lets the receiver dedup imported IOSurfaces. The pixel -/// dimensions are part of the identity because a resize can reuse the -/// same surfman id for a newly-sized IOSurface. `width` and `height` -/// are reported in surface pixels (post-DPR). -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -#[cfg_attr(feature = "servo-engine", derive(serde::Serialize, serde::Deserialize))] -pub struct IOSurfaceHandle { - pub mach_port_name: u32, - pub surface_id: u64, - pub width: u32, - pub height: u32, -} - -/// Identity-only peek of the currently bound IOSurface. Distinguishes -/// "same surface as last frame" from "resize/swap rotated to a new -/// surface" without minting a fresh mach port (mach ports are a scarce -/// kernel resource and `IOSurfaceCreateMachPort` is not cheap). -#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] -pub struct IOSurfaceIdentity { - pub surface_id: u64, - pub width: u32, - pub height: u32, -} - -impl IOSurfaceIdentity { - pub fn from_handle(handle: IOSurfaceHandle) -> Self { - Self { surface_id: handle.surface_id, width: handle.width, height: handle.height } - } -} diff --git a/crates/ely_servo_host/src/lib.rs b/crates/ely_servo_host/src/lib.rs index 5bec4e1..da1b0fc 100644 --- a/crates/ely_servo_host/src/lib.rs +++ b/crates/ely_servo_host/src/lib.rs @@ -1,8 +1,5 @@ mod error; -#[cfg(feature = "hardware-render")] -mod hardware_rendering_context; mod host; -mod iosurface_handle; #[cfg(feature = "servo-engine")] mod keyboard; #[cfg(feature = "servo-engine")] @@ -17,14 +14,11 @@ mod runtime_waker; mod runtime_webview; pub use error::ServoHostError; -#[cfg(feature = "hardware-render")] -pub use hardware_rendering_context::HardwareOffscreenContext; pub use host::{ HidpiScaleRequest, KeyboardTextRequest, MouseClickRequest, MouseDragRequest, MouseHoverRequest, NavigationRequest, PageZoomRequest, PermissionDecision, PermissionRequest, RenderedFrame, - RenderedFrameSummary, ResizeRequest, ScreenshotRequest, ScrollRequest, ServoHost, - TouchTapRequest, WebViewSnapshot, WebViewState, + RenderedFrameSummary, ResizeRequest, ScrollRequest, ServoHost, TouchTapRequest, + WebViewSnapshot, WebViewState, }; -pub use iosurface_handle::{IOSurfaceHandle, IOSurfaceIdentity}; #[cfg(feature = "servo-engine")] pub use runtime::{RenderingContextKind, ServoSurfaceSize, SoftwareServoHost}; diff --git a/crates/ely_servo_host/src/runtime.rs b/crates/ely_servo_host/src/runtime.rs index b57c43a..fe43262 100644 --- a/crates/ely_servo_host/src/runtime.rs +++ b/crates/ely_servo_host/src/runtime.rs @@ -4,18 +4,17 @@ use std::{ path::PathBuf, rc::Rc, sync::{ - Arc, + Arc, Once, atomic::{AtomicBool, Ordering}, }, - thread, - time::{Duration, Instant}, }; use dpi::PhysicalSize; use ely_domain::{ProfileId, TabId, WebViewId}; +use raw_window_handle::{HasDisplayHandle, HasWindowHandle}; use servo::{ - DevicePoint, DeviceVector2D, Opts, Scroll, Servo, ServoBuilder, WebViewBuilder, WebViewPoint, - WebViewVector, + DevicePoint, DeviceVector2D, Opts, Preferences, Scroll, Servo, ServoBuilder, WebViewBuilder, + WebViewPoint, WebViewVector, }; #[path = "runtime_context.rs"] @@ -28,8 +27,8 @@ use url::Url; use crate::{ HidpiScaleRequest, KeyboardTextRequest, MouseClickRequest, MouseDragRequest, MouseHoverRequest, NavigationRequest, PageZoomRequest, PermissionDecision, PermissionRequest, RenderedFrame, - ResizeRequest, ScreenshotRequest, ScrollRequest, ServoHost, ServoHostError, TouchTapRequest, - WebViewSnapshot, WebViewState, + ResizeRequest, ScrollRequest, ServoHost, ServoHostError, TouchTapRequest, WebViewSnapshot, + WebViewState, runtime_input::{ send_keyboard_text, send_mouse_click, send_mouse_drag, send_mouse_hover, send_touch_tap, }, @@ -39,8 +38,7 @@ use crate::{ }; static SERVO_RUNTIME_STARTED: AtomicBool = AtomicBool::new(false); -const SCREENSHOT_TIMEOUT: Duration = Duration::from_secs(20); -const SCREENSHOT_POLL_INTERVAL: Duration = Duration::from_millis(2); +static RUSTLS_PROVIDER: Once = Once::new(); pub struct SoftwareServoHost { servo: Servo, @@ -70,12 +68,6 @@ impl SoftwareServoHost { config_dir: Option, rendering_context_kind: RenderingContextKind, ) -> Result { - if rendering_context_kind == RenderingContextKind::Hardware - && !cfg!(feature = "hardware-render") - { - return Err(ServoHostError::HardwareRenderUnavailable); - } - if SERVO_RUNTIME_STARTED .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) .is_err() @@ -96,7 +88,22 @@ impl SoftwareServoHost { profile_id: ProfileId, size: ServoSurfaceSize, ) -> Result { - self.create_webview_in_context(tab_id, profile_id, size) + let handles = self.new_rendering_context(size)?; + self.create_webview_in_context(tab_id, profile_id, handles) + } + + pub fn create_webview_with_native_surface( + &mut self, + tab_id: TabId, + profile_id: ProfileId, + size: ServoSurfaceSize, + native_surface: &S, + ) -> Result + where + S: HasDisplayHandle + HasWindowHandle + ?Sized, + { + let handles = self.new_rendering_context_for_native_surface(size, native_surface)?; + self.create_webview_in_context(tab_id, profile_id, handles) } /// Paint and present the current surface without RGBA readback. @@ -121,8 +128,10 @@ impl SoftwareServoHost { config_dir: Option, rendering_context_kind: RenderingContextKind, ) -> Result { + install_rustls_provider(); let wake_requested = Arc::new(AtomicBool::new(false)); let mut builder = ServoBuilder::default() + .preferences(ely_servo_preferences()) .event_loop_waker(Box::new(ServoWakeFlag::new(wake_requested.clone()))); if let Some(config_dir) = config_dir { builder = builder.opts(Opts { config_dir: Some(config_dir), ..Opts::default() }); @@ -183,13 +192,24 @@ impl SoftwareServoHost { } } +fn install_rustls_provider() { + RUSTLS_PROVIDER.call_once(|| { + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + }); +} + +fn ely_servo_preferences() -> Preferences { + Preferences { dom_intersection_observer_enabled: true, ..Preferences::default() } +} + impl ServoHost for SoftwareServoHost { fn create_webview( &mut self, tab_id: TabId, profile_id: ProfileId, ) -> Result { - self.create_webview_in_context(tab_id, profile_id, self.default_surface_size) + let handles = self.new_rendering_context(self.default_surface_size)?; + self.create_webview_in_context(tab_id, profile_id, handles) } fn navigate(&mut self, request: NavigationRequest) -> Result<(), ServoHostError> { @@ -313,41 +333,6 @@ impl ServoHost for SoftwareServoHost { Ok(()) } - fn capture_screenshot( - &mut self, - request: ScreenshotRequest, - ) -> Result { - let webview = self.webview(&request.webview_id)?.webview.clone(); - let captured_image = Rc::new(RefCell::new(None)); - let callback_image = captured_image.clone(); - webview.take_screenshot(None, move |result| { - callback_image.replace(Some(result)); - }); - - let started_at = Instant::now(); - while captured_image.borrow().is_none() { - if started_at.elapsed() >= SCREENSHOT_TIMEOUT { - return Err(ServoHostError::ScreenshotTimedOut { id: request.webview_id.clone() }); - } - - self.tick(); - if self.snapshot(&request.webview_id)?.has_pending_frame() { - self.paint(&request.webview_id)?; - } - thread::sleep(SCREENSHOT_POLL_INTERVAL); - } - - let Some(result) = captured_image.borrow_mut().take() else { - return Err(ServoHostError::RenderedFrameUnavailable); - }; - let image = result.map_err(|error| ServoHostError::ScreenshotUnavailable { - reason: format!("{error:?}"), - })?; - let frame = RenderedFrame::from_rgba_bytes(image.width(), image.height(), image.into_raw()); - self.last_rendered_frame = Some(frame.clone()); - Ok(frame) - } - fn set_permission( &mut self, request: PermissionRequest, @@ -392,15 +377,20 @@ impl ServoHost for SoftwareServoHost { } } +impl Drop for SoftwareServoHost { + fn drop(&mut self) { + SERVO_RUNTIME_STARTED.store(false, Ordering::Release); + } +} + impl SoftwareServoHost { fn create_webview_in_context( &mut self, tab_id: TabId, profile_id: ProfileId, - size: ServoSurfaceSize, + handles: runtime_context::RenderingContextHandles, ) -> Result { let webview_id = WebViewId::new(); - let handles = self.new_rendering_context(size)?; let delegate = Rc::new(HostWebViewDelegate::new(profile_id.clone(), self.permissions.clone())); let webview = WebViewBuilder::new(&self.servo, handles.rendering_context.clone()) @@ -420,8 +410,6 @@ impl SoftwareServoHost { tab_id, profile_id, rendering_context: handles.rendering_context, - #[cfg(feature = "hardware-render")] - hardware_context: handles.hardware_context, webview, delegate, requested_url: None, @@ -431,42 +419,6 @@ impl SoftwareServoHost { Ok(webview_id) } - /// Cheap peek at the IOSurface identity bound to this webview's - /// hardware context. Returns `None` for software webviews and on - /// non-macOS hosts; otherwise the surfman `SurfaceID`-derived - /// identity plus dimensions. Used by the sidecar's live loop to - /// dedup mach port creation. - #[cfg(all(feature = "hardware-render", target_os = "macos"))] - pub fn peek_iosurface_identity( - &self, - webview_id: &WebViewId, - ) -> Result, ServoHostError> { - let webview = self.webview(webview_id)?; - let Some(hardware) = webview.hardware_context.as_ref() else { - return Ok(None); - }; - hardware.peek_iosurface_identity().map_err(|_| ServoHostError::RenderingContextUnavailable) - } - - /// Mint a fresh mach port for the IOSurface bound to this - /// webview's hardware context. The caller is responsible for - /// transferring the port to the receiving process; if no transfer - /// happens, the port leaks. Software webviews return `None`. - #[cfg(all(feature = "hardware-render", target_os = "macos"))] - pub fn current_iosurface_handle( - &self, - webview_id: &WebViewId, - ) -> Result, ServoHostError> { - let webview = self.webview(webview_id)?; - let Some(hardware) = webview.hardware_context.as_ref() else { - return Ok(None); - }; - hardware - .current_iosurface_mach_port() - .map(Some) - .map_err(|_| ServoHostError::RenderingContextUnavailable) - } - fn webview(&self, webview_id: &WebViewId) -> Result<&HostWebView, ServoHostError> { self.webviews .get(webview_id) diff --git a/crates/ely_servo_host/src/runtime_context.rs b/crates/ely_servo_host/src/runtime_context.rs index ceeab56..8845583 100644 --- a/crates/ely_servo_host/src/runtime_context.rs +++ b/crates/ely_servo_host/src/runtime_context.rs @@ -8,6 +8,7 @@ use std::{ use dpi::PhysicalSize; use euclid::Scale; +use raw_window_handle::{HasDisplayHandle, HasWindowHandle}; use servo::{ DeviceIndependentPixel, DeviceIntPoint, DeviceIntRect, DeviceIntSize, DevicePixel, RenderingContext, @@ -62,31 +63,16 @@ impl ServoSurfaceSize { } /// Selects the `RenderingContext` implementation each webview gets. -/// -/// `Software` uses Servo's built-in `SoftwareRenderingContext`, which -/// rasterises on the CPU. `Hardware` uses the vendored -/// [`HardwareOffscreenContext`](crate::HardwareOffscreenContext), -/// which rasterises through the real GPU adapter against a -/// `SurfaceType::Generic` offscreen surface. The `Hardware` variant -/// is only available when the `hardware-render` feature is enabled; -/// requesting it without the feature is a configuration error -/// surfaced via `ServoHostError::HardwareRenderUnavailable`. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum RenderingContextKind { #[default] Software, - Hardware, } /// Pair of rendering-context handles produced by -/// [`SoftwareServoHost::new_rendering_context`]. The trait-object -/// handle drives Servo's compositor; the concrete hardware handle is -/// kept on the side so the host can call macOS-specific methods -/// (IOSurface mach port extraction) without downcasting. +/// [`SoftwareServoHost::new_rendering_context`]. pub(super) struct RenderingContextHandles { pub(super) rendering_context: Rc, - #[cfg(feature = "hardware-render")] - pub(super) hardware_context: Option>, } impl SoftwareServoHost { @@ -103,29 +89,33 @@ impl SoftwareServoHost { rendering_context .make_current() .map_err(|_| ServoHostError::RenderingContextNotCurrent)?; - Ok(RenderingContextHandles { - rendering_context, - #[cfg(feature = "hardware-render")] - hardware_context: None, - }) + Ok(RenderingContextHandles { rendering_context }) } - #[cfg(feature = "hardware-render")] - RenderingContextKind::Hardware => { - let hardware = Rc::new( - crate::HardwareOffscreenContext::new(size.physical()) - .map_err(|_| ServoHostError::RenderingContextUnavailable)?, - ); - hardware.make_current().map_err(|_| ServoHostError::RenderingContextNotCurrent)?; - Ok(RenderingContextHandles { - rendering_context: hardware.clone(), - hardware_context: Some(hardware), - }) - } - #[cfg(not(feature = "hardware-render"))] - RenderingContextKind::Hardware => Err(ServoHostError::HardwareRenderUnavailable), } } + pub(super) fn new_rendering_context_for_native_surface( + &self, + size: ServoSurfaceSize, + native_surface: &S, + ) -> Result + where + S: HasDisplayHandle + HasWindowHandle + ?Sized, + { + let display_handle = native_surface + .display_handle() + .map_err(|_| ServoHostError::RenderingContextUnavailable)?; + let window_handle = native_surface + .window_handle() + .map_err(|_| ServoHostError::RenderingContextUnavailable)?; + let rendering_context = Rc::new( + servo::WindowRenderingContext::new(display_handle, window_handle, size.physical()) + .map_err(|_| ServoHostError::RenderingContextUnavailable)?, + ); + rendering_context.make_current().map_err(|_| ServoHostError::RenderingContextNotCurrent)?; + Ok(RenderingContextHandles { rendering_context }) + } + /// Spin Servo's event loop until the webview's delegate observes a /// fresh `notify_new_frame_ready` callback (i.e. the framebuffer is /// consistent for readback) or [`paint_barrier_budget`] elapses. The diff --git a/crates/ely_servo_host/src/runtime_webview.rs b/crates/ely_servo_host/src/runtime_webview.rs index d9091eb..808cb11 100644 --- a/crates/ely_servo_host/src/runtime_webview.rs +++ b/crates/ely_servo_host/src/runtime_webview.rs @@ -13,12 +13,6 @@ pub(super) struct HostWebView { pub(super) tab_id: TabId, pub(super) profile_id: ProfileId, pub(super) rendering_context: Rc, - /// Parallel concrete handle when the rendering context is the - /// vendored hardware path. `None` for software webviews. Lets the - /// host call macOS-specific methods (IOSurface mach port - /// extraction) without downcasting `dyn RenderingContext`. - #[cfg(feature = "hardware-render")] - pub(super) hardware_context: Option>, pub(super) webview: WebView, pub(super) delegate: Rc, pub(super) requested_url: Option, diff --git a/crates/ely_servo_host/tests/hardware_rendering_context.rs b/crates/ely_servo_host/tests/hardware_rendering_context.rs deleted file mode 100644 index 9a6e164..0000000 --- a/crates/ely_servo_host/tests/hardware_rendering_context.rs +++ /dev/null @@ -1,78 +0,0 @@ -//! Smoke test for the vendored hardware [`RenderingContext`]. -//! -//! Runs only when the `hardware-render` feature is enabled. The test -//! degrades gracefully when the host machine lacks a hardware GL -//! adapter (CI sandboxes, no-GPU containers): construction returns -//! `Err`, the test logs the cause, and reports `ok` — proving the -//! vendored constructor is wired up correctly without falsely -//! marking the suite green when a GPU is actually expected and -//! missing. Inverting that check (turning a GPU-missing host into a -//! hard failure) is left for downstream CI configuration once the -//! hardware path is wired into the sidecar binary. - -#![cfg(feature = "hardware-render")] - -use dpi::PhysicalSize; -use ely_servo_host::HardwareOffscreenContext; -use servo::RenderingContext; - -#[test] -fn constructs_or_explains_why_not() { - let size = PhysicalSize::new(640, 480); - match HardwareOffscreenContext::new(size) { - Ok(context) => { - // We don't drive Servo here — just confirm the vendored - // glue produced a live context. Construction is the - // failure mode this smoke test guards against; once a - // context exists the real Servo paint path exercises the - // rest of the surface. - drop(context); - } - Err(error) => { - eprintln!( - "hardware GL adapter not available on this host \ - (acceptable in headless / no-GPU environments): {error:?}" - ); - } - } -} - -#[cfg(target_os = "macos")] -#[test] -fn extracts_iosurface_mach_port_from_current_surface() -> Result<(), String> { - let width = 256; - let height = 192; - let context = match HardwareOffscreenContext::new(PhysicalSize::new(width, height)) { - Ok(context) => context, - Err(error) => { - eprintln!( - "hardware GL adapter not available on this host \ - (acceptable in headless / no-GPU environments): {error:?}" - ); - return Ok(()); - } - }; - - context.prepare_for_rendering(); - context.present(); - let first = context - .current_iosurface_mach_port() - .map_err(|error| format!("first IOSurface mach port extraction failed: {error:?}"))?; - assert!( - first.mach_port_name != 0, - "IOSurfaceCreateMachPort must return a non-null mach_port_t (got 0)" - ); - assert_eq!(first.width, width, "reported width must match surface width"); - assert_eq!(first.height, height, "reported height must match surface height"); - - // The unbind/rebind cycle must leave the context usable: a second - // call should still produce a valid mach port without panicking on - // a stale `Framebuffer::None`. - let second = context - .current_iosurface_mach_port() - .map_err(|error| format!("repeated mach port extraction failed: {error:?}"))?; - assert!(second.mach_port_name != 0); - assert_eq!(second.width, width); - assert_eq!(second.height, height); - Ok(()) -} diff --git a/crates/ely_servo_host/tests/live_perf_bench.rs b/crates/ely_servo_host/tests/live_perf_bench.rs deleted file mode 100644 index 457e41c..0000000 --- a/crates/ely_servo_host/tests/live_perf_bench.rs +++ /dev/null @@ -1,494 +0,0 @@ -//! Manual sidecar perf bench; ignored by normal CI. -#![cfg(feature = "servo-engine")] - -#[path = "live_perf_bench/pixels.rs"] -mod pixels; - -use std::{ - env, - error::Error, - fs, - io::{BufRead, BufReader, Read, Write}, - path::PathBuf, - process::{Child, ChildStdin, ChildStdout, Command, Stdio}, - thread, - time::{Duration, Instant}, -}; - -use ely_domain::{ProfileId, TabId}; -use serde::Deserialize; - -const DEFAULT_FRAMES: u32 = 240; -const VIEWPORT_WIDTH: u32 = 1024; -const VIEWPORT_HEIGHT: u32 = 768; -const SCROLL_STEP_PX: i32 = 4; -const RESPONSE_TIMEOUT: Duration = Duration::from_secs(20); -const SCROLL_PAGE_DATA_URL: &str = "data:text/html,\ -perf\ -\ -"; - -#[derive(Deserialize, Debug)] -struct LiveResponse { - error: Option, - frame: Option, - #[serde(default)] - perf: Option, - #[serde(default)] - surface_handle: Option, - #[serde(default)] - current_surface_id: Option, -} - -#[derive(Deserialize, Debug, Clone, Copy)] -struct BenchSurfaceHandle { - mach_port_name: u32, - surface_id: u64, - width: u32, - height: u32, -} - -#[derive(Deserialize, Debug)] -struct LiveFrameReport { - rgba_byte_count: usize, - #[serde(default)] - width: u32, - #[serde(default)] - height: u32, - #[serde(default)] - device_pixel_ratio: f32, - #[serde(default)] - css_viewport_width: u32, - #[serde(default)] - css_viewport_height: u32, -} - -#[derive(Deserialize, Debug, Clone)] -struct FramePerfSummary { - window: u32, - context: String, - paint_p50_us: u64, - paint_p95_us: u64, - paint_p99_us: u64, - encode_p50_us: u64, - encode_p95_us: u64, - encode_p99_us: u64, - write_p50_us: u64, - write_p95_us: u64, - write_p99_us: u64, - total_p50_us: u64, - total_p95_us: u64, - total_p99_us: u64, -} - -#[test] -#[ignore = "manual bench: spawns sidecar, scrolls a data: URL for N frames"] -fn run_live_bench() -> Result<(), Box> { - let kind = env::var("ELY_PERF_KIND").unwrap_or_else(|_| "software".to_string()); - let frames: u32 = env::var("ELY_PERF_FRAMES") - .ok() - .and_then(|value| value.parse().ok()) - .unwrap_or(DEFAULT_FRAMES); - let url = env::var("ELY_PERF_URL").unwrap_or_else(|_| SCROLL_PAGE_DATA_URL.to_string()); - - let profile_id = ProfileId::new(); - let tab = TabId::new(); - let profile_data_dir = env::temp_dir().join(format!( - "ely-perf-bench-{}-{}-{}", - std::process::id(), - kind, - profile_id.as_str() - )); - fs::create_dir_all(&profile_data_dir)?; - - let mut child = spawn_sidecar(&kind, &profile_data_dir)?; - let mut stdin = child.stdin.take().ok_or("sidecar stdin missing")?; - let stdout = child.stdout.take().ok_or("sidecar stdout missing")?; - let mut reader = BufReader::new(stdout); - - let outcome = match drive_bench(&mut stdin, &mut reader, &kind, &tab, &profile_id, &url, frames) - { - Ok(outcome) => outcome, - Err(error) => { - drop(stdin); - let _ = child.kill(); - cleanup(&profile_data_dir)?; - return Err(error); - } - }; - - drop(stdin); - let _ = child.wait(); - cleanup(&profile_data_dir)?; - - print_summaries(&kind, frames, &outcome.summaries); - print_surface_handles(&kind, &outcome.surface_handles); - print_current_surface_summary(&kind, &outcome.current_surface_ids); - eprintln!( - "\n=== ELY_PERF_KIND={kind} readback_rgba_bytes={} surface_rgba_bytes={} ===", - outcome.readback_rgba_bytes, outcome.surface_rgba_bytes, - ); - assert!( - !outcome.summaries.is_empty(), - "expected at least one FramePerfSummary across {frames} frames" - ); - if kind == "hardware" { - assert!( - !outcome.surface_handles.is_empty(), - "hardware live path must publish IOSurface handles" - ); - assert!( - !outcome.current_surface_ids.is_empty(), - "hardware live path must report current_surface_id selectors" - ); - } else { - assert!( - outcome.surface_handles.is_empty(), - "software path must never publish an IOSurface handle" - ); - assert!( - outcome.current_surface_ids.is_empty(), - "software path must never report current_surface_id" - ); - } - let viewport_bytes = (1024u64) * (768u64) * 4; - let total_rgba_bytes = outcome.readback_rgba_bytes + outcome.surface_rgba_bytes; - assert!( - total_rgba_bytes >= viewport_bytes, - "{kind} path delivered only {total_rgba_bytes} bytes — expected at least one full frame ({})", - viewport_bytes, - ); - if kind == "hardware" { - let full_readback_budget = viewport_bytes * u64::from(frames); - assert_eq!( - outcome.readback_rgba_bytes, viewport_bytes, - "hardware path should read back only the initial visible frame" - ); - assert!( - total_rgba_bytes < full_readback_budget, - "hardware path stayed on full readback: {total_rgba_bytes} >= {full_readback_budget}" - ); - } - Ok(()) -} - -struct BenchOutcome { - summaries: Vec, - surface_handles: Vec, - current_surface_ids: Vec, - readback_rgba_bytes: u64, - surface_rgba_bytes: u64, -} - -fn spawn_sidecar(kind: &str, profile_data_dir: &PathBuf) -> Result> { - let mut command = Command::new(env!("CARGO_BIN_EXE_ely_servo_sidecar")); - command - .arg("live") - .arg("--profile-data-dir") - .arg(profile_data_dir) - .arg("--rendering-context") - .arg(kind) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::inherit()); - Ok(command.spawn()?) -} - -fn drive_bench( - stdin: &mut ChildStdin, - reader: &mut BufReader, - kind: &str, - tab: &TabId, - profile_id: &ProfileId, - url: &str, - frames: u32, -) -> Result> { - let mut summaries = Vec::new(); - let mut surface_handles = Vec::new(); - let mut current_surface_ids = Vec::new(); - let mut readback_rgba_bytes: u64 = 0; - let mut surface_rgba_bytes: u64 = 0; - - let navigate = build_ensure(tab, profile_id, url, 0, 0, false); - write_request(stdin, &navigate)?; - let response = read_response(reader, RESPONSE_TIMEOUT)?; - assert_frame_viewport_report(&response); - record_summary(&response, kind, &mut summaries); - record_surface_handle(&response, kind, &mut surface_handles); - record_current_surface_id(&response, &mut current_surface_ids); - record_rgba_bytes(&response, &mut readback_rgba_bytes, &mut surface_rgba_bytes); - - let mut accumulated_scroll = 0; - let mut painted_frames = 0; - let max_attempts = frames.saturating_mul(4).max(frames + 10); - for attempt in 0..max_attempts { - if painted_frames >= frames { - break; - } - let scroll_delta_y = - if painted_frames % 80 == 79 { -SCROLL_STEP_PX * 60 } else { SCROLL_STEP_PX }; - accumulated_scroll += scroll_delta_y; - let request = build_ensure(tab, profile_id, url, 0, scroll_delta_y, true); - write_request(stdin, &request)?; - let response = read_response(reader, RESPONSE_TIMEOUT)?; - if let Some(error) = response.error.as_ref() { - return Err(format!("sidecar error at attempt {attempt}: {error}").into()); - } - if response.frame.is_some() { - painted_frames += 1; - } - assert_frame_viewport_report(&response); - record_summary(&response, kind, &mut summaries); - record_surface_handle(&response, kind, &mut surface_handles); - record_current_surface_id(&response, &mut current_surface_ids); - record_rgba_bytes(&response, &mut readback_rgba_bytes, &mut surface_rgba_bytes); - } - assert_eq!(painted_frames, frames, "bench did not receive the requested painted frame count"); - let _ = accumulated_scroll; - for _ in 0..5 { - if !summaries.is_empty() { - break; - } - let poll = build_poll(tab); - write_request(stdin, &poll)?; - let response = read_response(reader, RESPONSE_TIMEOUT)?; - record_summary(&response, kind, &mut summaries); - } - - Ok(BenchOutcome { - summaries, - surface_handles, - current_surface_ids, - readback_rgba_bytes, - surface_rgba_bytes, - }) -} - -fn record_rgba_bytes( - response: &LiveResponse, - readback_rgba_bytes: &mut u64, - surface_rgba_bytes: &mut u64, -) { - let rgba_byte_count = response.frame.as_ref().map_or(0, |frame| frame.rgba_byte_count as u64); - if rgba_byte_count > 0 { - *readback_rgba_bytes += rgba_byte_count; - } else if response.current_surface_id.is_some() { - *surface_rgba_bytes += rgba_byte_count; - } -} - -fn assert_frame_viewport_report(response: &LiveResponse) { - let Some(frame) = response.frame.as_ref() else { - return; - }; - let dpr = if frame.device_pixel_ratio.is_finite() && frame.device_pixel_ratio > 0.0 { - frame.device_pixel_ratio - } else { - 1.0 - }; - let expected_width = ((frame.width as f32) / dpr).round().max(1.0) as u32; - let expected_height = ((frame.height as f32) / dpr).round().max(1.0) as u32; - - assert_eq!( - frame.css_viewport_width, expected_width, - "CSS viewport width must match physical width divided by DPR", - ); - assert_eq!( - frame.css_viewport_height, expected_height, - "CSS viewport height must match physical height divided by DPR", - ); -} - -fn record_surface_handle( - response: &LiveResponse, - kind: &str, - surface_handles: &mut Vec, -) { - if let Some(handle) = response.surface_handle { - eprintln!( - "[iosurface {kind}] new surface_id=0x{:x} mach_port=0x{:x} {}x{}", - handle.surface_id, handle.mach_port_name, handle.width, handle.height, - ); - surface_handles.push(handle); - } -} - -fn record_current_surface_id(response: &LiveResponse, current_surface_ids: &mut Vec) { - if let Some(surface_id) = response.current_surface_id { - current_surface_ids.push(surface_id); - } -} - -fn print_surface_handles(kind: &str, surface_handles: &[BenchSurfaceHandle]) { - eprintln!( - "\n=== ELY_PERF_KIND={kind} iosurface_imports={} (one per unique surface) ===", - surface_handles.len() - ); - for (index, handle) in surface_handles.iter().enumerate() { - eprintln!( - "{:<4} surface_id=0x{:x} mach_port=0x{:x} {}x{}", - index, handle.surface_id, handle.mach_port_name, handle.width, handle.height, - ); - } -} - -fn print_current_surface_summary(kind: &str, current_surface_ids: &[u64]) { - use std::collections::BTreeMap; - let mut counts: BTreeMap = BTreeMap::new(); - for id in current_surface_ids { - *counts.entry(*id).or_default() += 1; - } - eprintln!("\n=== ELY_PERF_KIND={kind} current_surface_id histogram (per-frame selector) ===",); - for (surface_id, count) in counts.iter() { - eprintln!("surface_id=0x{:x} frames={}", surface_id, count); - } -} - -fn build_ensure( - tab: &TabId, - profile_id: &ProfileId, - url: &str, - scroll_dx: i32, - scroll_dy: i32, - include_hover: bool, -) -> String { - let hover_x = if include_hover { Some(256u32) } else { None }; - let hover_y = if include_hover { Some(256u32) } else { None }; - let scroll_point = if scroll_dx != 0 || scroll_dy != 0 { Some((256u32, 256u32)) } else { None }; - let hover_x_json = match hover_x { - Some(value) => format!("{value}"), - None => "null".to_string(), - }; - let hover_y_json = match hover_y { - Some(value) => format!("{value}"), - None => "null".to_string(), - }; - let scroll_point_x_json = match scroll_point { - Some((x, _)) => format!("{x}"), - None => "null".to_string(), - }; - let scroll_point_y_json = match scroll_point { - Some((_, y)) => format!("{y}"), - None => "null".to_string(), - }; - format!( - r#"{{"type":"ensure","tab_id":"{tab}","profile_id":"{profile}","url":{url},"width":{w},"height":{h},"page_zoom_percent":100,"scroll_delta_x":{dx},"scroll_delta_y":{dy},"scroll_point_x":{sx},"scroll_point_y":{sy},"click_x":null,"click_y":null,"hover_x":{hx},"hover_y":{hy},"typed_text":null,"site_permissions":[]}}"#, - tab = tab.as_str(), - profile = profile_id.as_str(), - url = serde_json::to_string(url).unwrap_or_else(|_| "\"\"".to_string()), - w = VIEWPORT_WIDTH, - h = VIEWPORT_HEIGHT, - dx = scroll_dx, - dy = scroll_dy, - sx = scroll_point_x_json, - sy = scroll_point_y_json, - hx = hover_x_json, - hy = hover_y_json, - ) -} - -fn build_poll(tab: &TabId) -> String { - format!(r#"{{"type":"poll","tab_id":"{}"}}"#, tab.as_str()) -} - -fn write_request(stdin: &mut ChildStdin, request: &str) -> Result<(), Box> { - stdin.write_all(request.as_bytes())?; - stdin.write_all(b"\n")?; - stdin.flush()?; - Ok(()) -} - -fn read_response( - reader: &mut BufReader, - timeout: Duration, -) -> Result> { - Ok(read_response_with_bytes(reader, timeout)?.0) -} - -fn read_response_with_bytes( - reader: &mut BufReader, - timeout: Duration, -) -> Result<(LiveResponse, Vec), Box> { - let started_at = Instant::now(); - let mut json_line = String::new(); - loop { - json_line.clear(); - let read_bytes = reader.read_line(&mut json_line)?; - if read_bytes == 0 { - return Err("sidecar closed stdout".into()); - } - if json_line.trim().is_empty() { - if started_at.elapsed() >= timeout { - return Err("sidecar response timeout".into()); - } - thread::sleep(Duration::from_millis(2)); - continue; - } - break; - } - let response: LiveResponse = serde_json::from_str(json_line.trim_end())?; - let mut rgba = Vec::new(); - if let Some(frame) = response.frame.as_ref() - && frame.rgba_byte_count > 0 - { - rgba.resize(frame.rgba_byte_count, 0); - reader.read_exact(&mut rgba)?; - } - Ok((response, rgba)) -} - -fn record_summary(response: &LiveResponse, kind: &str, summaries: &mut Vec) { - if let Some(perf) = response.perf.as_ref() { - assert_eq!(perf.context, kind, "sidecar context label must match requested kind"); - eprintln!( - "[perf {kind}] window={} paint p50/p95/p99={}/{}/{} encode {}/{}/{} write {}/{}/{} total {}/{}/{} (µs)", - perf.window, - perf.paint_p50_us, - perf.paint_p95_us, - perf.paint_p99_us, - perf.encode_p50_us, - perf.encode_p95_us, - perf.encode_p99_us, - perf.write_p50_us, - perf.write_p95_us, - perf.write_p99_us, - perf.total_p50_us, - perf.total_p95_us, - perf.total_p99_us, - ); - summaries.push(perf.clone()); - } -} - -fn print_summaries(kind: &str, frames: u32, summaries: &[FramePerfSummary]) { - eprintln!("\n=== ELY_PERF_KIND={kind} frames={frames} windows={} ===", summaries.len()); - for summary in summaries { - eprintln!( - "win={} paint={}/{}/{} encode={}/{}/{} write={}/{}/{} total={}/{}/{}", - summary.window, - summary.paint_p50_us, - summary.paint_p95_us, - summary.paint_p99_us, - summary.encode_p50_us, - summary.encode_p95_us, - summary.encode_p99_us, - summary.write_p50_us, - summary.write_p95_us, - summary.write_p99_us, - summary.total_p50_us, - summary.total_p95_us, - summary.total_p99_us, - ); - } -} - -fn cleanup(profile_data_dir: &PathBuf) -> Result<(), Box> { - match fs::remove_dir_all(profile_data_dir) { - Ok(()) => Ok(()), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), - Err(error) => Err(error.into()), - } -} diff --git a/crates/ely_servo_host/tests/live_perf_bench/pixels.rs b/crates/ely_servo_host/tests/live_perf_bench/pixels.rs deleted file mode 100644 index 33ad1e8..0000000 --- a/crates/ely_servo_host/tests/live_perf_bench/pixels.rs +++ /dev/null @@ -1,163 +0,0 @@ -use std::{ - env, - error::Error, - fs, - io::BufReader, - process::{ChildStdin, ChildStdout}, -}; - -use ely_domain::{ProfileId, TabId}; - -use super::{ - RESPONSE_TIMEOUT, build_ensure, cleanup, read_response_with_bytes, spawn_sidecar, write_request, -}; - -const SOLID_RED_DATA_URL: &str = - "data:text/html,"; -const SOLID_BLUE_DATA_URL: &str = - "data:text/html,"; - -#[test] -#[ignore = "drives a real sidecar via stdin/stdout; takes a few seconds"] -fn red_data_url_yields_red_rgba() -> Result<(), Box> { - assert_solid_color_renders("software", SOLID_RED_DATA_URL, ColorTarget::Red) -} - -#[test] -#[ignore = "drives a real sidecar via stdin/stdout; takes a few seconds"] -fn blue_data_url_yields_blue_rgba() -> Result<(), Box> { - assert_solid_color_renders("software", SOLID_BLUE_DATA_URL, ColorTarget::Blue) -} - -#[derive(Clone, Copy)] -enum ColorTarget { - Red, - Blue, -} - -impl ColorTarget { - fn label(self) -> &'static str { - match self { - ColorTarget::Red => "red", - ColorTarget::Blue => "blue", - } - } -} - -fn assert_solid_color_renders( - kind: &str, - url: &str, - target: ColorTarget, -) -> Result<(), Box> { - let profile_id = ProfileId::new(); - let tab = TabId::new(); - let profile_data_dir = env::temp_dir().join(format!( - "ely-pixel-{}-{}-{}", - std::process::id(), - target.label(), - profile_id.as_str(), - )); - fs::create_dir_all(&profile_data_dir)?; - - let mut child = spawn_sidecar(kind, &profile_data_dir)?; - let mut stdin = child.stdin.take().ok_or("sidecar stdin missing")?; - let stdout = child.stdout.take().ok_or("sidecar stdout missing")?; - let mut reader = BufReader::new(stdout); - let outcome = drive_solid_color_render(&mut stdin, &mut reader, &tab, &profile_id, url, target); - - drop(stdin); - let _ = child.wait(); - cleanup(&profile_data_dir)?; - outcome -} - -fn drive_solid_color_render( - stdin: &mut ChildStdin, - reader: &mut BufReader, - tab: &TabId, - profile_id: &ProfileId, - url: &str, - target: ColorTarget, -) -> Result<(), Box> { - let mut bytes = Vec::new(); - let mut report = None; - for iteration in 0..30 { - let scroll_y = if iteration == 0 { - 0 - } else if iteration % 2 == 1 { - 1 - } else { - -1 - }; - let request = build_ensure(tab, profile_id, url, 0, scroll_y, false); - write_request(stdin, &request)?; - let (response, response_bytes) = read_response_with_bytes(reader, RESPONSE_TIMEOUT)?; - if let Some(error) = response.error.as_ref() { - return Err(format!("sidecar error: {error}").into()); - } - if let Some(frame_report) = response.frame { - if !response_bytes.is_empty() - && sample_matches_target( - &response_bytes, - frame_report.width, - frame_report.height, - target, - ) - { - report = Some(frame_report); - bytes = response_bytes; - break; - } - if !response_bytes.is_empty() { - bytes = response_bytes; - report = Some(frame_report); - } - } - } - - let report = report.ok_or("never received a frame with bytes")?; - let width = report.width as usize; - let height = report.height as usize; - assert_eq!(bytes.len(), width * height * 4, "rgba byte count must match width * height * 4",); - - let mut samples = Vec::new(); - for fy in [1, 2, 3] { - for fx in [1, 2, 3] { - let x = width * fx / 4; - let y = height * fy / 4; - let idx = (y * width + x) * 4; - samples.push((x, y, bytes[idx], bytes[idx + 1], bytes[idx + 2], bytes[idx + 3])); - } - } - eprintln!("[pixel sample {}] {:?}", target.label(), samples); - - let hits = - samples.iter().filter(|(_x, _y, r, g, b, _a)| matches_color(*r, *g, *b, target)).count(); - assert!( - hits >= 5, - "expected at least 5/9 center-quadrant pixels to be {} after rendering {}; got samples {:?}", - target.label(), - url, - samples, - ); - Ok(()) -} - -fn sample_matches_target(bytes: &[u8], width: u32, height: u32, target: ColorTarget) -> bool { - let w = width as usize; - let h = height as usize; - if bytes.len() < w * h * 4 || w == 0 || h == 0 { - return false; - } - let cx = w / 2; - let cy = h / 2; - let idx = (cy * w + cx) * 4; - matches_color(bytes[idx], bytes[idx + 1], bytes[idx + 2], target) -} - -fn matches_color(r: u8, g: u8, b: u8, target: ColorTarget) -> bool { - match target { - ColorTarget::Red => r >= 200 && g <= 60 && b <= 60, - ColorTarget::Blue => r <= 60 && g <= 60 && b >= 200, - } -} diff --git a/crates/ely_servo_host/tests/sidecar.rs b/crates/ely_servo_host/tests/sidecar.rs deleted file mode 100644 index e0554a4..0000000 --- a/crates/ely_servo_host/tests/sidecar.rs +++ /dev/null @@ -1,215 +0,0 @@ -#![cfg(feature = "servo-engine")] - -use std::{collections::BTreeSet, error::Error, fs, path::PathBuf, process::Command}; - -use ely_domain::ProfileId; - -#[path = "sidecar/site_cases.rs"] -mod site_cases; -#[path = "sidecar/support.rs"] -mod support; - -use support::*; - -#[test] -fn sidecar_prd_reference_cases_cover_prd_urls() -> Result<(), Box> { - let prd = fs::read_to_string(prd_path())?; - let prd_urls = prd_reference_urls(&prd); - let covered_urls = PRD_REFERENCE_SITE_COMPATIBILITY_CASES - .iter() - .map(|case| normalized_url(case.url)) - .collect::>(); - let missing_urls = prd_urls - .iter() - .filter(|url| !covered_urls.contains(url.as_str())) - .cloned() - .collect::>(); - - assert!(missing_urls.is_empty(), "missing PRD sidecar smoke cases: {missing_urls:?}"); - assert_eq!(prd_urls.len(), PRD_REFERENCE_SITE_COMPATIBILITY_CASES.len()); - Ok(()) -} - -#[test] -fn sidecar_opens_and_renders_prd_sites_to_rgba_files() -> Result<(), Box> { - for case in PRD_SITE_COMPATIBILITY_CASES { - for size in PRD_SITE_COMPATIBILITY_SIZES { - snapshot_prd_site(case, *size, ScrollOffset::ZERO)?; - } - } - - Ok(()) -} - -#[test] -fn sidecar_report_uses_requested_profile_id() -> Result<(), Box> { - let profile_id = ProfileId::new(); - let profile_data_dir = std::env::temp_dir().join(format!( - "ely-servo-sidecar-profile-test-{}-{}", - std::process::id(), - profile_id.as_str() - )); - let rgba_path = std::env::temp_dir().join(format!( - "ely-servo-sidecar-profile-test-{}-{}.rgba", - std::process::id(), - profile_id.as_str() - )); - - let output = Command::new(env!("CARGO_BIN_EXE_ely_servo_sidecar")) - .arg("snapshot") - .arg("--url") - .arg("data:text/html,%3Ctitle%3EProfile%20Probe%3C%2Ftitle%3EProfile%20Probe") - .arg("--profile-id") - .arg(profile_id.as_str()) - .arg("--profile-data-dir") - .arg(&profile_data_dir) - .arg("--rgba-out") - .arg(&rgba_path) - .arg("--width") - .arg("64") - .arg("--height") - .arg("64") - .output()?; - - assert!( - output.status.success(), - "stdout: {}\nstderr: {}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - let report: serde_json::Value = serde_json::from_slice(&output.stdout)?; - assert_eq!( - report.get("profile_id").and_then(serde_json::Value::as_str), - Some(profile_id.as_str()) - ); - - remove_file_if_present(rgba_path)?; - remove_dir_if_present(profile_data_dir)?; - Ok(()) -} - -fn prd_path() -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..").join("..").join("PRD.md") -} - -fn remove_file_if_present(path: PathBuf) -> Result<(), Box> { - match fs::remove_file(path) { - Ok(()) => Ok(()), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), - Err(error) => Err(error.into()), - } -} - -fn remove_dir_if_present(path: PathBuf) -> Result<(), Box> { - match fs::remove_dir_all(path) { - Ok(()) => Ok(()), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), - Err(error) => Err(error.into()), - } -} - -fn prd_reference_urls(prd: &str) -> Vec { - prd.lines() - .filter(|line| line.starts_with("[R")) - .filter_map(|line| { - let start = line.find("https://")?; - let url = line[start..].split_whitespace().next()?; - Some(normalized_url(url)) - }) - .collect() -} - -fn normalized_url(url: &str) -> String { - url.trim().trim_end_matches('/').to_string() -} - -#[test] -fn sidecar_opens_and_renders_prd_reference_sites_to_rgba_files() -> Result<(), Box> { - for case in PRD_REFERENCE_SITE_COMPATIBILITY_CASES { - snapshot_prd_site(case, PRD_REFERENCE_SITE_SIZE, ScrollOffset::ZERO)?; - } - - Ok(()) -} - -#[test] -fn sidecar_scrolls_prd_site_with_servo_input() -> Result<(), Box> { - let scrolled_report = - snapshot_prd_site(&SERVO_SCROLL_SITE, SERVO_SCROLL_SIZE, SERVO_SCROLL_OFFSET)?; - - assert_eq!(report_field_as_i64(&scrolled_report, "scroll_x")?, SERVO_SCROLL_OFFSET.x); - assert_eq!(report_field_as_i64(&scrolled_report, "scroll_y")?, SERVO_SCROLL_OFFSET.y); - assert_eq!(report_field_as_u64(&scrolled_report, "width")?, SERVO_SCROLL_SIZE.width); - assert!(report_field_as_bool(&scrolled_report, "scroll_changed_frame")?); - - Ok(()) -} - -#[test] -fn sidecar_clicks_page_with_servo_mouse_input() -> Result<(), Box> { - let initial_report = snapshot_click_probe(None)?; - let clicked_report = snapshot_click_probe(Some(SERVO_CLICK_POINT))?; - - assert_eq!(report_field_as_u64(&clicked_report, "click_x")?, SERVO_CLICK_POINT.x); - assert_eq!(report_field_as_u64(&clicked_report, "click_y")?, SERVO_CLICK_POINT.y); - assert!(report_field_as_bool(&clicked_report, "click_changed_frame")?); - assert_ne!( - report_field_as_u64(&initial_report, "sample_hash")?, - report_field_as_u64(&clicked_report, "sample_hash")? - ); - - Ok(()) -} - -#[test] -fn sidecar_drags_page_with_servo_mouse_input() -> Result<(), Box> { - let initial_report = snapshot_drag_probe(None)?; - let drag_points = DragPoints { from: SERVO_DRAG_FROM, to: SERVO_DRAG_TO }; - let dragged_report = snapshot_drag_probe(Some(drag_points))?; - - assert_eq!(report_field_as_u64(&dragged_report, "drag_from_x")?, SERVO_DRAG_FROM.x); - assert_eq!(report_field_as_u64(&dragged_report, "drag_from_y")?, SERVO_DRAG_FROM.y); - assert_eq!(report_field_as_u64(&dragged_report, "drag_to_x")?, SERVO_DRAG_TO.x); - assert_eq!(report_field_as_u64(&dragged_report, "drag_to_y")?, SERVO_DRAG_TO.y); - assert!(report_field_as_bool(&dragged_report, "drag_changed_frame")?); - assert_ne!( - report_field_as_u64(&initial_report, "sample_hash")?, - report_field_as_u64(&dragged_report, "sample_hash")? - ); - - Ok(()) -} - -#[test] -fn sidecar_touches_page_with_servo_touch_input() -> Result<(), Box> { - let initial_report = snapshot_touch_probe(None)?; - let touched_report = snapshot_touch_probe(Some(SERVO_TOUCH_POINT))?; - - assert_eq!(report_field_as_u64(&touched_report, "touch_x")?, SERVO_TOUCH_POINT.x); - assert_eq!(report_field_as_u64(&touched_report, "touch_y")?, SERVO_TOUCH_POINT.y); - assert!(report_field_as_bool(&touched_report, "touch_changed_frame")?); - assert_ne!( - report_field_as_u64(&initial_report, "sample_hash")?, - report_field_as_u64(&touched_report, "sample_hash")? - ); - - Ok(()) -} - -#[test] -fn sidecar_types_text_with_servo_keyboard_input() -> Result<(), Box> { - let initial_report = snapshot_text_probe(None)?; - let typed_report = snapshot_text_probe(Some(SERVO_TEXT_VALUE))?; - - assert_eq!( - report_field_as_u64(&typed_report, "typed_text_byte_count")?, - SERVO_TEXT_VALUE.len() as u64 - ); - assert!(report_field_as_bool(&typed_report, "text_changed_frame")?); - assert_ne!( - report_field_as_u64(&initial_report, "sample_hash")?, - report_field_as_u64(&typed_report, "sample_hash")? - ); - - Ok(()) -} diff --git a/crates/ely_servo_host/tests/sidecar/site_cases.rs b/crates/ely_servo_host/tests/sidecar/site_cases.rs deleted file mode 100644 index a118464..0000000 --- a/crates/ely_servo_host/tests/sidecar/site_cases.rs +++ /dev/null @@ -1,141 +0,0 @@ -pub(super) const PRD_SITE_COMPATIBILITY_CASES: &[PrdSiteCompatibilityCase] = &[ - PrdSiteCompatibilityCase { url: "https://github.com", title_fragment: "GitHub" }, - PrdSiteCompatibilityCase { url: "https://example.com", title_fragment: "Example Domain" }, - PrdSiteCompatibilityCase { url: "https://servo.org/", title_fragment: "Servo" }, -]; - -pub(super) const PRD_REFERENCE_SITE_COMPATIBILITY_CASES: &[PrdSiteCompatibilityCase] = &[ - PrdSiteCompatibilityCase { - url: "https://blog.google/products-and-platforms/products/chrome/new-chrome-productivity-features/", - title_fragment: "Chrome", - }, - PrdSiteCompatibilityCase { - url: "https://www.microsoft.com/en-us/edge/features/vertical-tabs", - title_fragment: "Microsoft Edge", - }, - PrdSiteCompatibilityCase { - url: "https://resources.arc.net/hc/en-us/articles/19230755904151-Favorites-Top-Tabs-Across-Every-Space", - title_fragment: "Favorites", - }, - PrdSiteCompatibilityCase { - url: "https://resources.arc.net/hc/en-us/articles/19228855311127-Auto-Archive-Clean-as-you-go", - title_fragment: "Auto Archive", - }, - PrdSiteCompatibilityCase { - url: "https://vivaldi.com/features/workspaces/", - title_fragment: "Workspaces", - }, - PrdSiteCompatibilityCase { - url: "https://help.vivaldi.com/desktop/tabs/tab-tiling/", - title_fragment: "Tab Tiling", - }, - PrdSiteCompatibilityCase { url: "https://www.gpui.rs/", title_fragment: "gpui" }, - PrdSiteCompatibilityCase { url: "https://docs.rs/gpui", title_fragment: "gpui" }, - PrdSiteCompatibilityCase { - url: "https://zed.dev/blog/videogame", - title_fragment: "Leveraging Rust", - }, - PrdSiteCompatibilityCase { - url: "https://github.com/longbridge/gpui-component/", - title_fragment: "gpui-component", - }, - PrdSiteCompatibilityCase { - url: "https://github.com/zed-industries/awesome-gpui/", - title_fragment: "awesome-gpui", - }, - PrdSiteCompatibilityCase { url: "https://servo.org/", title_fragment: "Servo" }, - PrdSiteCompatibilityCase { - url: "https://servo.org/blog/2026/04/13/servo-0.1.0-release/", - title_fragment: "Servo", - }, - PrdSiteCompatibilityCase { - url: "https://developers.cloudflare.com/d1/", - title_fragment: "Cloudflare", - }, - PrdSiteCompatibilityCase { - url: "https://developers.cloudflare.com/workers/platform/storage-options/", - title_fragment: "Cloudflare", - }, - PrdSiteCompatibilityCase { - url: "https://developers.cloudflare.com/kv/concepts/how-kv-works/", - title_fragment: "Cloudflare", - }, - PrdSiteCompatibilityCase { - url: "https://better-auth.com/blog/1-5", - title_fragment: "Better Auth", - }, - PrdSiteCompatibilityCase { - url: "https://developers.cloudflare.com/d1/platform/limits/", - title_fragment: "Cloudflare", - }, - PrdSiteCompatibilityCase { - url: "https://component-model.bytecodealliance.org/", - title_fragment: "WebAssembly Component Model", - }, - PrdSiteCompatibilityCase { - url: "https://docs.wasmtime.dev/api/wasmtime/component/index.html", - title_fragment: "wasmtime", - }, - PrdSiteCompatibilityCase { - url: "https://docs.wasmtime.dev/security.html", - title_fragment: "Wasmtime", - }, -]; - -pub(super) const PRD_SITE_COMPATIBILITY_SIZES: &[FrameSize] = &[ - FrameSize { width: 640, height: 480 }, - FrameSize { width: 934, height: 657 }, - FrameSize { width: 1614, height: 980 }, -]; -pub(super) const PRD_REFERENCE_SITE_SIZE: FrameSize = FrameSize { width: 934, height: 657 }; -pub(super) const SERVO_SCROLL_SITE: PrdSiteCompatibilityCase = - PrdSiteCompatibilityCase { url: "https://servo.org/", title_fragment: "Servo" }; -pub(super) const SERVO_SCROLL_SIZE: FrameSize = FrameSize { width: 934, height: 657 }; -pub(super) const SERVO_SCROLL_OFFSET: ScrollOffset = ScrollOffset { x: 0, y: 480 }; -pub(super) const SERVO_CLICK_URL: &str = "data:text/html,%3C!doctype%20html%3E%3Ctitle%3EClick%20Probe%3C%2Ftitle%3E%3Cstyle%3Ebody%7Bmargin%3A0%3Bbackground%3A%23f7f7f7%3B%7Dbutton%7Bposition%3Aabsolute%3Bleft%3A80px%3Btop%3A80px%3Bwidth%3A220px%3Bheight%3A90px%3Bfont%3A28px%20sans-serif%3Bbackground%3A%23ffffff%3Bcolor%3A%23111111%3B%7D%3C%2Fstyle%3E%3Cbutton%20onclick%3D%22document.body.style.background%3D%27%230039ff%27%3Bdocument.title%3D%27Clicked%27%3Bthis.textContent%3D%27Clicked%27%3B%22%3ETap%3C%2Fbutton%3E"; -pub(super) const SERVO_CLICK_SIZE: FrameSize = FrameSize { width: 640, height: 480 }; -pub(super) const SERVO_CLICK_POINT: ClickPoint = ClickPoint { x: 160, y: 120 }; -pub(super) const SERVO_DRAG_URL: &str = "data:text/html,%3C%21doctype%20html%3E%3Ctitle%3EDrag%20Probe%3C%2Ftitle%3E%3Cstyle%3Ebody%7Bmargin%3A0%3Bbackground%3A%23f7f7f7%3B%7Dbutton%7Bposition%3Aabsolute%3Bleft%3A80px%3Btop%3A80px%3Bwidth%3A220px%3Bheight%3A90px%3Bfont%3A28px%20sans-serif%3Bbackground%3A%23ffffff%3Bcolor%3A%23111111%3B%7D%3C%2Fstyle%3E%3Cbutton%20id%3Dbox%3EDrag%3C%2Fbutton%3E%3Cscript%3Elet%20dragging%3Dfalse%3Bconst%20box%3Ddocument.getElementById%28%27box%27%29%3BaddEventListener%28%27mousedown%27%2Cevent%3D%3E%7Bif%28event.target%3D%3D%3Dbox%29%7Bdragging%3Dtrue%3B%7D%7D%29%3BaddEventListener%28%27mousemove%27%2Cevent%3D%3E%7Bif%28dragging%26%26event.clientX%3E280%29%7Bdocument.body.style.background%3D%27%230039ff%27%3Bdocument.title%3D%27Dragged%27%3Bbox.textContent%3D%27Dragged%27%3B%7D%7D%29%3BaddEventListener%28%27mouseup%27%2C%28%29%3D%3E%7Bdragging%3Dfalse%3B%7D%29%3B%3C%2Fscript%3E"; -pub(super) const SERVO_DRAG_SIZE: FrameSize = FrameSize { width: 640, height: 480 }; -pub(super) const SERVO_DRAG_FROM: ClickPoint = ClickPoint { x: 160, y: 120 }; -pub(super) const SERVO_DRAG_TO: ClickPoint = ClickPoint { x: 320, y: 120 }; -pub(super) const SERVO_TOUCH_URL: &str = "data:text/html,%3C%21doctype%20html%3E%3Ctitle%3ETouch%20Probe%3C%2Ftitle%3E%3Cstyle%3Ebody%7Bmargin%3A0%3Bbackground%3A%23f7f7f7%3B%7Dbutton%7Bposition%3Aabsolute%3Bleft%3A80px%3Btop%3A80px%3Bwidth%3A220px%3Bheight%3A90px%3Bfont%3A28px%20sans-serif%3Bbackground%3A%23ffffff%3Bcolor%3A%23111111%3Btouch-action%3Amanipulation%3B%7D%3C%2Fstyle%3E%3Cbutton%20ontouchstart%3D%22document.body.dataset.touch%3D%27start%27%3B%22%20onclick%3D%22document.body.style.background%3D%27%230039ff%27%3Bdocument.title%3D%27Touched%27%3Bthis.textContent%3D%27Touched%27%3B%22%3ETap%3C%2Fbutton%3E"; -pub(super) const SERVO_TOUCH_SIZE: FrameSize = FrameSize { width: 640, height: 480 }; -pub(super) const SERVO_TOUCH_POINT: ClickPoint = ClickPoint { x: 160, y: 120 }; -pub(super) const SERVO_TEXT_URL: &str = "data:text/html,%3C!doctype%20html%3E%3Ctitle%3EText%20Probe%3C%2Ftitle%3E%3Cstyle%3Ebody%7Bmargin%3A0%3Bbackground%3A%23f7f7f7%3Bfont%3A28px%20sans-serif%3B%7Dinput%7Bposition%3Aabsolute%3Bleft%3A80px%3Btop%3A80px%3Bwidth%3A260px%3Bheight%3A70px%3Bfont%3A28px%20sans-serif%3B%7Doutput%7Bposition%3Aabsolute%3Bleft%3A80px%3Btop%3A180px%3Bfont%3A32px%20sans-serif%3B%7D%3C%2Fstyle%3E%3Cinput%20id%3Dq%20autofocus%20oninput%3D%22document.body.style.background%3D%27%230039ff%27%3Bdocument.getElementById%28%27out%27%29.textContent%3Dthis.value%3B%22%3E%3Coutput%20id%3Dout%3Eempty%3C%2Foutput%3E"; -pub(super) const SERVO_TEXT_SIZE: FrameSize = FrameSize { width: 640, height: 480 }; -pub(super) const SERVO_TEXT_POINT: ClickPoint = ClickPoint { x: 160, y: 120 }; -pub(super) const SERVO_TEXT_VALUE: &str = "ely42"; - -pub(super) struct PrdSiteCompatibilityCase { - pub(super) url: &'static str, - pub(super) title_fragment: &'static str, -} - -#[derive(Clone, Copy)] -pub(super) struct FrameSize { - pub(super) width: u64, - pub(super) height: u64, -} - -#[derive(Clone, Copy)] -pub(super) struct ScrollOffset { - pub(super) x: i64, - pub(super) y: i64, -} - -impl ScrollOffset { - pub(super) const ZERO: Self = Self { x: 0, y: 0 }; -} - -#[derive(Clone, Copy)] -pub(super) struct ClickPoint { - pub(super) x: u64, - pub(super) y: u64, -} - -#[derive(Clone, Copy)] -pub(super) struct DragPoints { - pub(super) from: ClickPoint, - pub(super) to: ClickPoint, -} diff --git a/crates/ely_servo_host/tests/sidecar/support.rs b/crates/ely_servo_host/tests/sidecar/support.rs deleted file mode 100644 index dc7271b..0000000 --- a/crates/ely_servo_host/tests/sidecar/support.rs +++ /dev/null @@ -1,443 +0,0 @@ -use std::{ - error::Error, - io, - path::{Path, PathBuf}, - process::{Child, Command, Output, Stdio}, - sync::Mutex, - thread, - time::{Duration, Instant}, -}; - -use ely_domain::ProfileId; - -pub(super) use super::site_cases::{ - ClickPoint, DragPoints, FrameSize, PRD_REFERENCE_SITE_COMPATIBILITY_CASES, - PRD_REFERENCE_SITE_SIZE, PRD_SITE_COMPATIBILITY_CASES, PRD_SITE_COMPATIBILITY_SIZES, - PrdSiteCompatibilityCase, SERVO_CLICK_POINT, SERVO_DRAG_FROM, SERVO_DRAG_TO, - SERVO_SCROLL_OFFSET, SERVO_SCROLL_SITE, SERVO_SCROLL_SIZE, SERVO_TEXT_VALUE, SERVO_TOUCH_POINT, - ScrollOffset, -}; -use super::site_cases::{ - SERVO_CLICK_SIZE, SERVO_CLICK_URL, SERVO_DRAG_SIZE, SERVO_DRAG_URL, SERVO_TEXT_POINT, - SERVO_TEXT_SIZE, SERVO_TEXT_URL, SERVO_TOUCH_SIZE, SERVO_TOUCH_URL, -}; - -pub(super) const MINIMUM_CONTENT_PIXELS: u64 = 1_000; -const SIDECAR_TIMEOUT: Duration = Duration::from_secs(45); -const SIDECAR_POLL_INTERVAL: Duration = Duration::from_millis(20); -const SIDECAR_COMMAND_COOLDOWN: Duration = Duration::from_millis(750); -const SIDECAR_RETRY_INTERVAL: Duration = Duration::from_millis(250); -const SIDECAR_MAX_ATTEMPTS: usize = 3; -static SIDECAR_COMMAND_LOCK: Mutex<()> = Mutex::new(()); - -#[derive(Clone, Copy, Default)] -struct SnapshotInput<'a> { - click_point: Option, - drag_points: Option, - touch_point: Option, - typed_text: Option<&'a str>, -} - -pub(super) fn snapshot_prd_site( - case: &PrdSiteCompatibilityCase, - size: FrameSize, - scroll_offset: ScrollOffset, -) -> Result> { - let site_name = case - .url - .chars() - .map(|character| if character.is_ascii_alphanumeric() { character } else { '-' }) - .collect::(); - let output_path = std::env::temp_dir().join(format!( - "ely-servo-sidecar-{}-{site_name}-{}x{}-{}-{}.rgba", - std::process::id(), - size.width, - size.height, - scroll_offset.x, - scroll_offset.y - )); - - snapshot_prd_site_with_retry(case, &output_path, size, scroll_offset) -} - -fn snapshot_prd_site_with_retry( - case: &PrdSiteCompatibilityCase, - output_path: &Path, - size: FrameSize, - scroll_offset: ScrollOffset, -) -> Result> { - for attempt in 0..SIDECAR_MAX_ATTEMPTS { - match snapshot_prd_site_once(case, output_path, size, scroll_offset) { - Ok(report) => return Ok(report), - Err(error) if attempt + 1 == SIDECAR_MAX_ATTEMPTS => return Err(error), - Err(_) => remove_file_if_present(output_path)?, - } - - thread::sleep(SIDECAR_RETRY_INTERVAL); - } - - Err("sidecar PRD snapshot retry did not produce output".into()) -} - -fn snapshot_prd_site_once( - case: &PrdSiteCompatibilityCase, - output_path: &Path, - size: FrameSize, - scroll_offset: ScrollOffset, -) -> Result> { - let output = run_sidecar_snapshot_with_retry( - case.url, - output_path, - size, - scroll_offset, - SnapshotInput::default(), - )?; - - assert!( - output.status.success(), - "{} {}x{}\nstatus: {:?}\nstdout: {}\nstderr: {}", - case.url, - size.width, - size.height, - output.status.code(), - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - - let report: serde_json::Value = serde_json::from_slice(&output.stdout)?; - assert_report_state_is_renderable(&report)?; - assert_eq!(report_field_as_u64(&report, "width")?, size.width, "{}", case.url); - assert_eq!(report_field_as_u64(&report, "height")?, size.height, "{}", case.url); - assert_eq!( - report_field_as_u64(&report, "rgba_byte_count")?, - size.width * size.height * 4, - "{}", - case.url - ); - assert_report_text_contains(&report, "loaded_url", case.url)?; - assert_report_text_equals(&report, "requested_url", case.url)?; - assert_report_text_contains(&report, "title", case.title_fragment)?; - assert!(report_field_as_u64(&report, "non_white_pixel_count")? > 0, "{}", case.url); - assert!( - report_field_as_u64(&report, "content_pixel_count")? >= MINIMUM_CONTENT_PIXELS, - "{}", - case.url - ); - assert!(report_field_as_u64(&report, "sample_hash")? > 0, "{}", case.url); - assert_eq!(std::fs::metadata(output_path)?.len(), size.width * size.height * 4); - - log_prd_report(&report, case, size)?; - - std::fs::remove_file(output_path)?; - Ok(report) -} - -fn remove_file_if_present(path: &Path) -> Result<(), Box> { - match std::fs::remove_file(path) { - Ok(()) => Ok(()), - Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), - Err(error) => Err(error.into()), - } -} - -pub(super) fn snapshot_click_probe( - click_point: Option, -) -> Result> { - snapshot_probe( - SERVO_CLICK_URL, - SERVO_CLICK_SIZE, - "click", - SnapshotInput { click_point, ..SnapshotInput::default() }, - ) -} - -pub(super) fn snapshot_drag_probe( - drag_points: Option, -) -> Result> { - snapshot_probe( - SERVO_DRAG_URL, - SERVO_DRAG_SIZE, - "drag", - SnapshotInput { drag_points, ..SnapshotInput::default() }, - ) -} - -pub(super) fn snapshot_touch_probe( - touch_point: Option, -) -> Result> { - snapshot_probe( - SERVO_TOUCH_URL, - SERVO_TOUCH_SIZE, - "touch", - SnapshotInput { touch_point, ..SnapshotInput::default() }, - ) -} - -pub(super) fn snapshot_text_probe( - typed_text: Option<&str>, -) -> Result> { - snapshot_probe( - SERVO_TEXT_URL, - SERVO_TEXT_SIZE, - "text", - SnapshotInput { - click_point: typed_text.map(|_| SERVO_TEXT_POINT), - typed_text, - ..SnapshotInput::default() - }, - ) -} - -fn snapshot_probe( - url: &str, - size: FrameSize, - label: &'static str, - input: SnapshotInput<'_>, -) -> Result> { - let output_path = std::env::temp_dir().join(format!( - "ely-servo-sidecar-{}-{label}-{}x{}.rgba", - std::process::id(), - size.width, - size.height - )); - - if output_path.exists() { - std::fs::remove_file(&output_path)?; - } - - let output = - run_sidecar_snapshot_with_retry(url, &output_path, size, ScrollOffset::ZERO, input)?; - - assert!( - output.status.success(), - "{label} probe\nstatus: {:?}\nstdout: {}\nstderr: {}", - output.status.code(), - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - - let report: serde_json::Value = serde_json::from_slice(&output.stdout)?; - assert_eq!(report_field_as_u64(&report, "width")?, size.width); - assert_eq!(report_field_as_u64(&report, "height")?, size.height); - assert!(report_field_as_u64(&report, "content_pixel_count")? > 0); - assert_eq!(std::fs::metadata(&output_path)?.len(), size.width * size.height * 4); - - std::fs::remove_file(&output_path)?; - Ok(report) -} - -fn run_sidecar_snapshot( - site_url: &str, - output_path: &Path, - size: FrameSize, - scroll_offset: ScrollOffset, - input: SnapshotInput<'_>, -) -> Result> { - let _guard = SIDECAR_COMMAND_LOCK - .lock() - .map_err(|_| io::Error::other("sidecar command lock poisoned"))?; - let profile_id = ProfileId::new(); - let profile_data_dir = temporary_profile_data_dir(&profile_id); - let mut command = Command::new(env!("CARGO_BIN_EXE_ely_servo_sidecar")); - command - .arg("snapshot") - .arg("--url") - .arg(site_url) - .arg("--profile-id") - .arg(profile_id.as_str()) - .arg("--profile-data-dir") - .arg(&profile_data_dir) - .arg("--rgba-out") - .arg(output_path) - .arg("--width") - .arg(size.width.to_string()) - .arg("--height") - .arg(size.height.to_string()); - if scroll_offset.x != 0 { - command.arg("--scroll-x").arg(scroll_offset.x.to_string()); - } - if scroll_offset.y != 0 { - command.arg("--scroll-y").arg(scroll_offset.y.to_string()); - } - if let Some(click_point) = input.click_point { - command.arg("--click-x").arg(click_point.x.to_string()); - command.arg("--click-y").arg(click_point.y.to_string()); - } - if let Some(drag_points) = input.drag_points { - command.arg("--drag-from-x").arg(drag_points.from.x.to_string()); - command.arg("--drag-from-y").arg(drag_points.from.y.to_string()); - command.arg("--drag-to-x").arg(drag_points.to.x.to_string()); - command.arg("--drag-to-y").arg(drag_points.to.y.to_string()); - } - if let Some(touch_point) = input.touch_point { - command.arg("--touch-x").arg(touch_point.x.to_string()); - command.arg("--touch-y").arg(touch_point.y.to_string()); - } - if let Some(typed_text) = input.typed_text { - command.arg("--type-text").arg(typed_text); - } - - let mut child = command.stdout(Stdio::piped()).stderr(Stdio::piped()).spawn()?; - let started_at = Instant::now(); - loop { - if child.try_wait()?.is_some() { - let output = child.wait_with_output()?; - remove_temporary_dir(&profile_data_dir)?; - thread::sleep(SIDECAR_COMMAND_COOLDOWN); - return Ok(output); - } - - if started_at.elapsed() >= SIDECAR_TIMEOUT { - terminate_child(child)?; - remove_temporary_dir(&profile_data_dir)?; - thread::sleep(SIDECAR_COMMAND_COOLDOWN); - return Err(format!( - "timed out rendering {site_url} at {}x{}", - size.width, size.height - ) - .into()); - } - - thread::sleep(SIDECAR_POLL_INTERVAL); - } -} - -fn temporary_profile_data_dir(profile_id: &ProfileId) -> PathBuf { - std::env::temp_dir().join(format!( - "ely-servo-sidecar-profile-{}-{}", - std::process::id(), - profile_id.as_str() - )) -} - -fn remove_temporary_dir(path: &Path) -> Result<(), Box> { - match std::fs::remove_dir_all(path) { - Ok(()) => Ok(()), - Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), - Err(error) => Err(error.into()), - } -} - -fn run_sidecar_snapshot_with_retry( - site_url: &str, - output_path: &std::path::Path, - size: FrameSize, - scroll_offset: ScrollOffset, - input: SnapshotInput<'_>, -) -> Result> { - for attempt in 0..SIDECAR_MAX_ATTEMPTS { - if output_path.exists() { - std::fs::remove_file(output_path)?; - } - - match run_sidecar_snapshot(site_url, output_path, size, scroll_offset, input) { - Ok(output) if output.status.success() => return Ok(output), - Ok(output) if attempt + 1 == SIDECAR_MAX_ATTEMPTS => return Ok(output), - Ok(_output) => {} - Err(error) if attempt + 1 == SIDECAR_MAX_ATTEMPTS => return Err(error), - Err(_error) => {} - } - - thread::sleep(SIDECAR_RETRY_INTERVAL); - } - - Err("sidecar snapshot retry did not produce output".into()) -} - -fn terminate_child(mut child: Child) -> Result<(), Box> { - match child.kill() { - Ok(()) => { - let _output = child.wait_with_output()?; - Ok(()) - } - Err(error) if error.kind() == io::ErrorKind::InvalidInput => Ok(()), - Err(error) => Err(error.into()), - } -} - -fn assert_report_text_equals( - report: &serde_json::Value, - field: &'static str, - expected: &str, -) -> Result<(), Box> { - let value = report_field_as_text(report, field)?; - if value == expected { Ok(()) } else { Err(format!("{field}: {value}").into()) } -} - -fn assert_report_text_contains( - report: &serde_json::Value, - field: &'static str, - fragment: &str, -) -> Result<(), Box> { - let value = report_field_as_text(report, field)?; - if value.contains(fragment) { Ok(()) } else { Err(format!("{field}: {value}").into()) } -} - -fn assert_report_state_is_renderable(report: &serde_json::Value) -> Result<(), Box> { - let state = report_field_as_text(report, "state")?; - if matches!(state, "complete" | "loading") { - Ok(()) - } else { - Err(format!("state: {state}").into()) - } -} - -fn log_prd_report( - report: &serde_json::Value, - case: &PrdSiteCompatibilityCase, - size: FrameSize, -) -> Result<(), Box> { - eprintln!( - "prd-live-site servo-sidecar url={} loaded={} title={} state={} size={}x{} content_pixels={} non_white_pixels={} sample_hash={}", - case.url, - report_field_as_text(report, "loaded_url")?, - report_field_as_text(report, "title")?, - report_field_as_text(report, "state")?, - size.width, - size.height, - report_field_as_u64(report, "content_pixel_count")?, - report_field_as_u64(report, "non_white_pixel_count")?, - report_field_as_u64(report, "sample_hash")? - ); - Ok(()) -} - -fn report_field_as_text<'a>( - report: &'a serde_json::Value, - field: &'static str, -) -> Result<&'a str, Box> { - report - .get(field) - .and_then(serde_json::Value::as_str) - .ok_or_else(|| format!("missing text report field: {field}").into()) -} - -pub(super) fn report_field_as_bool( - report: &serde_json::Value, - field: &'static str, -) -> Result> { - report - .get(field) - .and_then(serde_json::Value::as_bool) - .ok_or_else(|| format!("missing boolean report field: {field}").into()) -} - -pub(super) fn report_field_as_i64( - report: &serde_json::Value, - field: &'static str, -) -> Result> { - report - .get(field) - .and_then(serde_json::Value::as_i64) - .ok_or_else(|| format!("missing signed report field: {field}").into()) -} - -pub(super) fn report_field_as_u64( - report: &serde_json::Value, - field: &'static str, -) -> Result> { - report - .get(field) - .and_then(serde_json::Value::as_u64) - .ok_or_else(|| format!("missing numeric report field: {field}").into()) -} diff --git a/crates/ely_servo_host/tests/software_host.rs b/crates/ely_servo_host/tests/software_host.rs index 0e61b18..bbf0790 100644 --- a/crates/ely_servo_host/tests/software_host.rs +++ b/crates/ely_servo_host/tests/software_host.rs @@ -11,9 +11,8 @@ use std::{ use ely_domain::{ProfileId, SiteOrigin, SitePermissionFeature, TabId, UrlText}; use ely_servo_host::{ HidpiScaleRequest, KeyboardTextRequest, MouseClickRequest, MouseDragRequest, NavigationRequest, - PageZoomRequest, PermissionDecision, PermissionRequest, ResizeRequest, ScreenshotRequest, - ScrollRequest, ServoHost, ServoHostError, ServoSurfaceSize, SoftwareServoHost, TouchTapRequest, - WebViewState, + PageZoomRequest, PermissionDecision, PermissionRequest, ResizeRequest, ScrollRequest, + ServoHost, ServoHostError, ServoSurfaceSize, SoftwareServoHost, TouchTapRequest, WebViewState, }; const MINIMUM_CONTENT_PIXELS: u64 = 1_000; @@ -336,17 +335,6 @@ fn exercise_real_servo_webview_lifecycle() -> Result<(), Box> { site.url ); assert_rendered_frame_has_content(&host, site.url, MINIMUM_CONTENT_PIXELS)?; - if site.url == "https://example.com" { - let screenshot = - host.capture_screenshot(ScreenshotRequest { webview_id: webview_id.clone() })?; - assert_frame_has_dimensions_and_content( - &screenshot, - "https://example.com screenshot", - INITIAL_WIDTH, - INITIAL_HEIGHT, - MINIMUM_CONTENT_PIXELS, - ); - } previous_frame_hash = Some(host.last_rendered_frame()?.sample_hash()); } diff --git a/docs/servo-embedding-architecture.md b/docs/servo-embedding-architecture.md index bd4d4e0..306324e 100644 --- a/docs/servo-embedding-architecture.md +++ b/docs/servo-embedding-architecture.md @@ -2,46 +2,52 @@ ## Decision -ELY is a Servo-based browser. The page renderer is Servo itself, embedded in the application process and attached to a real platform rendering surface. The browser chrome can stay GPUI, while web content must follow Servo's embedder model: +ELY is a Servo-based browser. The page renderer lives in the application process and follows Servo's embedder model: ```text ┌──────────────────────────── ELY App Process ────────────────────────────┐ -│ │ -│ GPUI chrome │ -│ ┌───────────────────────────────────────────────────────────────────┐ │ -│ │ Sidebar Toolbar Tabs Settings │ │ -│ └───────────────────────────────────────────────────────────────────┘ │ -│ │ -│ Servo content host │ -│ ┌───────────────────────────────────────────────────────────────────┐ │ -│ │ Servo + WebView + WindowRenderingContext │ │ -│ │ notify_new_frame_ready -> window repaint -> paint -> present │ │ -│ └───────────────────────────────────────────────────────────────────┘ │ -│ │ -└─────────────────────────────────────────────────────────────────────────┘ +│ │ +│ GPUI chrome │ +│ ┌────────────────────────────────────────────────────────────────────┐ │ +│ │ Sidebar Toolbar Tabs Settings │ │ +│ └────────────────────────────────────────────────────────────────────┘ │ +│ │ +│ Servo content host │ +│ ┌────────────────────────────────────────────────────────────────────┐ │ +│ │ Servo + WebView + RenderingContext │ │ +│ │ notify_new_frame_ready -> repaint -> paint -> present │ │ +│ └────────────────────────────────────────────────────────────────────┘ │ +│ │ +└──────────────────────────────────────────────────────────────────────────┘ ``` -The normal page-display path excludes external rendering sidecars, stdout frame transport, RGBA frame payloads, cross-process IOSurface handoff, and GPUI `RenderImage` uploads for live web content. +The app process owns WebView lifecycle, navigation, input, permissions, frame readiness, and rendering context presentation. -## Root Cause - -The current ELY page path is a remote-frame architecture: +## Current Code Boundary ```text -GPUI shell - -> WebSurfaceStore +WebSurfaceStore + -> GPUI NativeSurface -> LiveRuntimeWorker -> ServoLiveClient - -> ely_servo_sidecar stdin/stdout JSON -> SoftwareServoHost - -> Servo WebView paint - -> RGBA payload or IOSurface handle - -> GPUI surface/image element + -> servo::Servo + servo::WebView + servo::WindowRenderingContext ``` -This makes page interaction depend on worker scheduling, IPC, polling cadence, surface import, frame object churn, and GPUI scene refresh. Hardware IOSurface reduces byte volume, while the architecture still behaves like a remote compositor. +`ServoLiveClient` is now an in-process adapter over `ely_servo_host::SoftwareServoHost`. It shares one Servo runtime across profile scopes, creates multiple WebViews inside that runtime, and routes scroll, hover, click, keyboard, resize, zoom, navigation, and permissions through the `ServoHost` API. -Servo's own embedder route is direct: +Normal page display now enters GPUI through `native_surface(...)`. GPUI creates a platform child surface for the content bounds, passes that raw handle into Servo's `WindowRenderingContext`, and presents with `paint_without_readback_with_completion`. The RGBA readback path remains inside `ely_servo_host` for low-level tests. + +Current platform child surfaces: + +- macOS: child `NSView` with an AppKit raw window handle. +- Windows: child `HWND` with a Win32 raw window handle. +- Linux/X11: child XCB window with XCB display/window handles. +- Linux/Wayland: child `wl_surface` attached as a `wl_subsurface` with Wayland display/surface handles. + +## Upstream Servo Route + +Servo's own shell route is the model for the final ELY rendering path: ```text Window event @@ -57,7 +63,7 @@ Relevant upstream evidence from Servo `7c48af7`: - `ports/servoshell/window.rs` creates `WebViewBuilder::new(state.servo(), platform_window.rendering_context())`. - `ports/servoshell/window.rs` repaints with `webview.paint()` and `rendering_context().present()`. - `ports/servoshell/running_app_state.rs` handles `notify_new_frame_ready` by marking the owning window for repaint. -- `components/paint/paint.rs` owns one WebRender painter per `RenderingContext` and explicitly avoids blocking paint on the constellation. +- `components/paint/paint.rs` owns one WebRender painter per `RenderingContext` and keeps paint coordination inside Servo's rendering pipeline. ## Target Boundaries @@ -73,11 +79,11 @@ crates/ely_app/src/servo_embed WebView lifecycle, repaint dispatch, and web input routing. crates/ely_servo_host - Transitional compatibility surface for explicit screenshots and isolated - compatibility tools. Normal live page display leaves this crate. + Owns the current in-process compatibility adapter while the native + platform rendering surface lands. ``` -Each production source file in the new embedding path stays below 500 lines. Large responsibilities split by ownership: +Each production source file in the final embedding path stays below 500 lines. Large responsibilities split by ownership: - `runtime.rs`: `Servo`, wake handling, webview registry. - `platform_view.rs`: platform content surface attachment. @@ -88,36 +94,33 @@ Each production source file in the new embedding path stays below 500 lines. Lar ## Migration Slices -1. Create `servo_embed` as an in-process module behind the existing tab/domain model. -2. Add a macOS platform content view using the GPUI window's raw AppKit handle. -3. Build Servo `WindowRenderingContext` or child context against the platform content view. -4. Move one active tab to in-process `Servo + WebView + RenderingContext`. -5. Route scroll, mouse, keyboard, resize, zoom, and navigation directly into the active `WebView`. -6. Replace `WebSurfaceStore` for normal web pages with the in-process host. -7. Delete sidecar spawning from normal page display. -8. Keep explicit page screenshot capture as a user-command path only. +1. App process owns the Servo runtime and WebView registry. +2. App process routes web input and navigation directly into Servo. +3. GPUI exposes one `NativeSurfaceHandle` element contract for platform child surfaces. +4. macOS creates a child `NSView` and hands the AppKit raw handle to Servo. +5. Windows creates a child `HWND` and hands the Win32 raw handle to Servo. +6. Linux/X11 creates a child XCB window and hands the XCB raw handle to Servo. +7. Linux/Wayland creates a child `wl_surface`/subsurface and hands the Wayland raw handle to Servo. +8. GPUI web page display uses native content surface presentation for every platform target. ## Acceptance Gates -- `cargo run` opens a normal web page without starting `ely_servo_sidecar`. +- `cargo run` opens a normal web page with Servo inside the ELY app process. - Scrolling a live web page uses Servo input events and Servo repaint callbacks. -- The page display path contains no RGBA frame payload transport. -- The page display path contains no stdout JSON frame loop. -- The page display path contains no GPUI `RenderImage` upload for live web content. -- Address/search, tabs, spaces, profiles, settings, permissions, sync, and explicit screenshots continue to compile and behave through existing domain APIs. +- Page display presents through a Servo rendering context. +- macOS, Windows, Linux/X11, and Linux/Wayland each have a platform child surface implementation under the same `NativeSurfaceHandle` contract. +- Address/search, tabs, spaces, profiles, settings, permissions, and sync compile through existing domain APIs. - Every new source file stays below 500 lines. -- No user-facing frontend status, logs, debug panels, or explanatory clutter are added. +- User-facing chrome stays clean. -## First Implementation Target - -The first code slice is macOS content-view attachment: +## First Platform Surface Target ```text GPUI Window - -> raw AppKit NSView - -> ELY child NSView for web content bounds + -> GPUI NativeSurface element + -> platform child surface for web content bounds -> Servo WindowRenderingContext -> Servo WebView ``` -This slice creates the real platform surface required by Servo's direct rendering model. Once the content view exists, Servo can paint into a native surface in the ELY app process, and the remote-frame path can be removed tab by tab. +This slice gives Servo a native surface in the ELY app process. The compatibility adapter remains a narrow bridge for tests and non-normal display probes. diff --git a/generated-icons/android/ic_launcher_hdpi.png b/generated-icons/android/ic_launcher_hdpi.png new file mode 100644 index 0000000..60283ba Binary files /dev/null and b/generated-icons/android/ic_launcher_hdpi.png differ diff --git a/generated-icons/android/ic_launcher_mdpi.png b/generated-icons/android/ic_launcher_mdpi.png new file mode 100644 index 0000000..016ba2b Binary files /dev/null and b/generated-icons/android/ic_launcher_mdpi.png differ diff --git a/generated-icons/android/ic_launcher_xhdpi.png b/generated-icons/android/ic_launcher_xhdpi.png new file mode 100644 index 0000000..0567ceb Binary files /dev/null and b/generated-icons/android/ic_launcher_xhdpi.png differ diff --git a/generated-icons/android/ic_launcher_xxhdpi.png b/generated-icons/android/ic_launcher_xxhdpi.png new file mode 100644 index 0000000..67c7c2b Binary files /dev/null and b/generated-icons/android/ic_launcher_xxhdpi.png differ diff --git a/generated-icons/android/ic_launcher_xxxhdpi.png b/generated-icons/android/ic_launcher_xxxhdpi.png new file mode 100644 index 0000000..4855a54 Binary files /dev/null and b/generated-icons/android/ic_launcher_xxxhdpi.png differ diff --git a/generated-icons/android/playstore-512.png b/generated-icons/android/playstore-512.png new file mode 100644 index 0000000..7f4b811 Binary files /dev/null and b/generated-icons/android/playstore-512.png differ diff --git a/generated-icons/icon-source-1024.png b/generated-icons/icon-source-1024.png new file mode 100644 index 0000000..a0785f0 Binary files /dev/null and b/generated-icons/icon-source-1024.png differ diff --git a/generated-icons/ios/Contents.json b/generated-icons/ios/Contents.json new file mode 100644 index 0000000..0975593 --- /dev/null +++ b/generated-icons/ios/Contents.json @@ -0,0 +1,80 @@ +{ + "images": [ + { + "size": "20x20", + "idiom": "iphone", + "filename": "icon-40.png", + "scale": "2x" + }, + { + "size": "20x20", + "idiom": "iphone", + "filename": "icon-60.png", + "scale": "3x" + }, + { + "size": "29x29", + "idiom": "iphone", + "filename": "icon-58.png", + "scale": "2x" + }, + { + "size": "29x29", + "idiom": "iphone", + "filename": "icon-87.png", + "scale": "3x" + }, + { + "size": "40x40", + "idiom": "iphone", + "filename": "icon-80.png", + "scale": "2x" + }, + { + "size": "40x40", + "idiom": "iphone", + "filename": "icon-120.png", + "scale": "3x" + }, + { + "size": "60x60", + "idiom": "iphone", + "filename": "icon-120.png", + "scale": "2x" + }, + { + "size": "60x60", + "idiom": "iphone", + "filename": "icon-180.png", + "scale": "3x" + }, + { + "size": "76x76", + "idiom": "ipad", + "filename": "icon-76.png", + "scale": "1x" + }, + { + "size": "76x76", + "idiom": "ipad", + "filename": "icon-152.png", + "scale": "2x" + }, + { + "size": "83.5x83.5", + "idiom": "ipad", + "filename": "icon-167.png", + "scale": "2x" + }, + { + "size": "1024x1024", + "idiom": "ios-marketing", + "filename": "icon-1024.png", + "scale": "1x" + } + ], + "info": { + "version": 1, + "author": "xcode" + } +} \ No newline at end of file diff --git a/generated-icons/ios/icon-1024.png b/generated-icons/ios/icon-1024.png new file mode 100644 index 0000000..a0785f0 Binary files /dev/null and b/generated-icons/ios/icon-1024.png differ diff --git a/generated-icons/ios/icon-120.png b/generated-icons/ios/icon-120.png new file mode 100644 index 0000000..8f9a25b Binary files /dev/null and b/generated-icons/ios/icon-120.png differ diff --git a/generated-icons/ios/icon-152.png b/generated-icons/ios/icon-152.png new file mode 100644 index 0000000..245a12f Binary files /dev/null and b/generated-icons/ios/icon-152.png differ diff --git a/generated-icons/ios/icon-167.png b/generated-icons/ios/icon-167.png new file mode 100644 index 0000000..f933cd5 Binary files /dev/null and b/generated-icons/ios/icon-167.png differ diff --git a/generated-icons/ios/icon-180.png b/generated-icons/ios/icon-180.png new file mode 100644 index 0000000..4fe73fc Binary files /dev/null and b/generated-icons/ios/icon-180.png differ diff --git a/generated-icons/ios/icon-40.png b/generated-icons/ios/icon-40.png new file mode 100644 index 0000000..35ff99a Binary files /dev/null and b/generated-icons/ios/icon-40.png differ diff --git a/generated-icons/ios/icon-58.png b/generated-icons/ios/icon-58.png new file mode 100644 index 0000000..ab1b017 Binary files /dev/null and b/generated-icons/ios/icon-58.png differ diff --git a/generated-icons/ios/icon-60.png b/generated-icons/ios/icon-60.png new file mode 100644 index 0000000..c7c3af9 Binary files /dev/null and b/generated-icons/ios/icon-60.png differ diff --git a/generated-icons/ios/icon-76.png b/generated-icons/ios/icon-76.png new file mode 100644 index 0000000..8741f9a Binary files /dev/null and b/generated-icons/ios/icon-76.png differ diff --git a/generated-icons/ios/icon-80.png b/generated-icons/ios/icon-80.png new file mode 100644 index 0000000..2c3e6ac Binary files /dev/null and b/generated-icons/ios/icon-80.png differ diff --git a/generated-icons/ios/icon-87.png b/generated-icons/ios/icon-87.png new file mode 100644 index 0000000..d7ca205 Binary files /dev/null and b/generated-icons/ios/icon-87.png differ diff --git a/generated-icons/macos/AppIcon.icns b/generated-icons/macos/AppIcon.icns new file mode 100644 index 0000000..cbdea11 Binary files /dev/null and b/generated-icons/macos/AppIcon.icns differ diff --git a/generated-icons/macos/AppIcon.iconset/icon_128x128.png b/generated-icons/macos/AppIcon.iconset/icon_128x128.png new file mode 100644 index 0000000..abb6b25 Binary files /dev/null and b/generated-icons/macos/AppIcon.iconset/icon_128x128.png differ diff --git a/generated-icons/macos/AppIcon.iconset/icon_128x128@2x.png b/generated-icons/macos/AppIcon.iconset/icon_128x128@2x.png new file mode 100644 index 0000000..2169c7c Binary files /dev/null and b/generated-icons/macos/AppIcon.iconset/icon_128x128@2x.png differ diff --git a/generated-icons/macos/AppIcon.iconset/icon_16x16.png b/generated-icons/macos/AppIcon.iconset/icon_16x16.png new file mode 100644 index 0000000..2270f0b Binary files /dev/null and b/generated-icons/macos/AppIcon.iconset/icon_16x16.png differ diff --git a/generated-icons/macos/AppIcon.iconset/icon_16x16@2x.png b/generated-icons/macos/AppIcon.iconset/icon_16x16@2x.png new file mode 100644 index 0000000..11c769e Binary files /dev/null and b/generated-icons/macos/AppIcon.iconset/icon_16x16@2x.png differ diff --git a/generated-icons/macos/AppIcon.iconset/icon_256x256.png b/generated-icons/macos/AppIcon.iconset/icon_256x256.png new file mode 100644 index 0000000..2169c7c Binary files /dev/null and b/generated-icons/macos/AppIcon.iconset/icon_256x256.png differ diff --git a/generated-icons/macos/AppIcon.iconset/icon_256x256@2x.png b/generated-icons/macos/AppIcon.iconset/icon_256x256@2x.png new file mode 100644 index 0000000..7f4b811 Binary files /dev/null and b/generated-icons/macos/AppIcon.iconset/icon_256x256@2x.png differ diff --git a/generated-icons/macos/AppIcon.iconset/icon_32x32.png b/generated-icons/macos/AppIcon.iconset/icon_32x32.png new file mode 100644 index 0000000..11c769e Binary files /dev/null and b/generated-icons/macos/AppIcon.iconset/icon_32x32.png differ diff --git a/generated-icons/macos/AppIcon.iconset/icon_32x32@2x.png b/generated-icons/macos/AppIcon.iconset/icon_32x32@2x.png new file mode 100644 index 0000000..444b1ed Binary files /dev/null and b/generated-icons/macos/AppIcon.iconset/icon_32x32@2x.png differ diff --git a/generated-icons/macos/AppIcon.iconset/icon_512x512.png b/generated-icons/macos/AppIcon.iconset/icon_512x512.png new file mode 100644 index 0000000..7f4b811 Binary files /dev/null and b/generated-icons/macos/AppIcon.iconset/icon_512x512.png differ diff --git a/generated-icons/macos/AppIcon.iconset/icon_512x512@2x.png b/generated-icons/macos/AppIcon.iconset/icon_512x512@2x.png new file mode 100644 index 0000000..a0785f0 Binary files /dev/null and b/generated-icons/macos/AppIcon.iconset/icon_512x512@2x.png differ diff --git a/generated-icons/web/apple-touch-icon.png b/generated-icons/web/apple-touch-icon.png new file mode 100644 index 0000000..4fe73fc Binary files /dev/null and b/generated-icons/web/apple-touch-icon.png differ diff --git a/generated-icons/web/favicon.ico b/generated-icons/web/favicon.ico new file mode 100644 index 0000000..8a2e72f Binary files /dev/null and b/generated-icons/web/favicon.ico differ diff --git a/generated-icons/web/icon-128.png b/generated-icons/web/icon-128.png new file mode 100644 index 0000000..abb6b25 Binary files /dev/null and b/generated-icons/web/icon-128.png differ diff --git a/generated-icons/web/icon-144.png b/generated-icons/web/icon-144.png new file mode 100644 index 0000000..67c7c2b Binary files /dev/null and b/generated-icons/web/icon-144.png differ diff --git a/generated-icons/web/icon-152.png b/generated-icons/web/icon-152.png new file mode 100644 index 0000000..245a12f Binary files /dev/null and b/generated-icons/web/icon-152.png differ diff --git a/generated-icons/web/icon-16.png b/generated-icons/web/icon-16.png new file mode 100644 index 0000000..2270f0b Binary files /dev/null and b/generated-icons/web/icon-16.png differ diff --git a/generated-icons/web/icon-192.png b/generated-icons/web/icon-192.png new file mode 100644 index 0000000..4855a54 Binary files /dev/null and b/generated-icons/web/icon-192.png differ diff --git a/generated-icons/web/icon-32.png b/generated-icons/web/icon-32.png new file mode 100644 index 0000000..11c769e Binary files /dev/null and b/generated-icons/web/icon-32.png differ diff --git a/generated-icons/web/icon-384.png b/generated-icons/web/icon-384.png new file mode 100644 index 0000000..1565934 Binary files /dev/null and b/generated-icons/web/icon-384.png differ diff --git a/generated-icons/web/icon-48.png b/generated-icons/web/icon-48.png new file mode 100644 index 0000000..016ba2b Binary files /dev/null and b/generated-icons/web/icon-48.png differ diff --git a/generated-icons/web/icon-512.png b/generated-icons/web/icon-512.png new file mode 100644 index 0000000..7f4b811 Binary files /dev/null and b/generated-icons/web/icon-512.png differ diff --git a/generated-icons/web/icon-72.png b/generated-icons/web/icon-72.png new file mode 100644 index 0000000..60283ba Binary files /dev/null and b/generated-icons/web/icon-72.png differ diff --git a/generated-icons/web/icon-96.png b/generated-icons/web/icon-96.png new file mode 100644 index 0000000..0567ceb Binary files /dev/null and b/generated-icons/web/icon-96.png differ diff --git a/scripts/create_macos_app_bundle.sh b/scripts/create_macos_app_bundle.sh index 8e40915..0977a75 100755 --- a/scripts/create_macos_app_bundle.sh +++ b/scripts/create_macos_app_bundle.sh @@ -3,21 +3,17 @@ set -euo pipefail repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" binary_path="${repo_root}/target/debug/ely_app" -sidecar_path="${repo_root}/target/debug/ely_servo_sidecar" bundle_root="${repo_root}/target/macos/ELY Browser.app" contents_dir="${bundle_root}/Contents" macos_dir="${contents_dir}/MacOS" resources_dir="${contents_dir}/Resources" cargo build -p ely_app -cargo build -p ely_servo_host --features servo-engine,hardware-render --bin ely_servo_sidecar rm -rf "${bundle_root}" mkdir -p "${macos_dir}" "${resources_dir}" cp "${repo_root}/packaging/macos/Info.plist" "${contents_dir}/Info.plist" cp "${binary_path}" "${macos_dir}/ely_app" -cp "${sidecar_path}" "${macos_dir}/ely_servo_sidecar" chmod 755 "${macos_dir}/ely_app" -chmod 755 "${macos_dir}/ely_servo_sidecar" echo "${bundle_root}" diff --git a/scripts/generate_icon.py b/scripts/generate_icon.py new file mode 100644 index 0000000..656cc60 --- /dev/null +++ b/scripts/generate_icon.py @@ -0,0 +1,207 @@ +#!/usr/bin/env python3 +"""Generate ELY Browser app icon — source PNG + all platform sizes.""" + +from PIL import Image, ImageDraw, ImageFilter +import math +import os +import json + +RENDER = 3072 +FINAL = 1024 +GRAD_SMALL = 512 # larger base for smoother gradient + +BASE = os.path.dirname(os.path.dirname(__file__)) +OUT_DIR = os.path.join(BASE, "generated-icons") +os.makedirs(OUT_DIR, exist_ok=True) + + +def create_gradient(size): + img = Image.new("RGB", (size, size)) + for y in range(size): + for x in range(size): + nx, ny = x / size, y / size + r, g, b = 236.0, 224.0, 243.0 + + dx = (nx - 0.80) * 1.7 + dy = (ny - 0.14) * 2.1 + d = min(1.0, math.sqrt(dx * dx + dy * dy)) + w = max(0.0, 1.0 - d) ** 1.7 + r += (255 - r) * w; g += (195 - g) * w; b += (212 - b) * w + + dx = (nx - 0.17) * 1.8 + dy = (ny - 0.87) * 1.7 + d = min(1.0, math.sqrt(dx * dx + dy * dy)) + w = max(0.0, 1.0 - d) ** 1.7 + r += (170 - r) * w; g += (190 - g) * w; b += (255 - b) * w + + dx = (nx - 0.22) * 2.0 + dy = (ny - 0.22) * 2.0 + d = min(1.0, math.sqrt(dx * dx + dy * dy)) + w = max(0.0, 1.0 - d) ** 2.2 * 0.35 + r += (255 - r) * w; g += (222 - g) * w; b += (208 - b) * w + + img.putpixel((x, y), (min(255, int(r)), min(255, int(g)), min(255, int(b)))) + return img + + +def draw_e_rounded(draw, cx, cy, sz): + """Draw geometric E with slightly rounded bar ends.""" + e_h = int(sz * 0.40) + e_w = int(sz * 0.30) + mid_w = int(e_w * 0.70) + bar = int(sz * 0.064) + cr = int(bar * 0.28) # corner radius for exposed ends + + left = cx - e_w // 2 + top = cy - e_h // 2 + right = left + e_w + bottom = top + e_h + mid_y = cy + white = (255, 255, 255, 255) + + # Vertical bar — full height, rounded top-left and bottom-left corners + draw.rounded_rectangle([left, top, left + bar, bottom], radius=cr, fill=white) + # Top bar — overlaps vertical bar at left end + draw.rounded_rectangle([left, top, right, top + bar], radius=cr, fill=white) + # Middle bar (shorter) + draw.rounded_rectangle( + [left, mid_y - bar // 2, left + mid_w, mid_y + bar // 2], + radius=cr, fill=white, + ) + # Bottom bar + draw.rounded_rectangle([left, bottom - bar, right, bottom], radius=cr, fill=white) + + +def main(): + print("Creating gradient (512x512)...") + grad_small = create_gradient(GRAD_SMALL) + grad = grad_small.resize((RENDER, RENDER), Image.BICUBIC) + # Slight blur to eliminate any banding + grad = grad.filter(ImageFilter.GaussianBlur(radius=2)) + + print("Building icon at 3072...") + corner_r = int(RENDER * 0.223) + mask = Image.new("L", (RENDER, RENDER), 0) + ImageDraw.Draw(mask).rounded_rectangle( + [0, 0, RENDER - 1, RENDER - 1], radius=corner_r, fill=255 + ) + + icon = Image.new("RGBA", (RENDER, RENDER), (0, 0, 0, 0)) + icon.paste(grad.convert("RGBA"), mask=mask) + + cx, cy = RENDER // 2, RENDER // 2 + + # Subtle drop shadow for the E + shadow = Image.new("RGBA", (RENDER, RENDER), (0, 0, 0, 0)) + draw_e_rounded(ImageDraw.Draw(shadow), cx, cy + int(RENDER * 0.005), RENDER) + # Convert white shadow pixels to dark translucent + sd = shadow.load() + for sy in range(RENDER): + for sx in range(RENDER): + _, _, _, a = sd[sx, sy] + if a > 0: + sd[sx, sy] = (25, 18, 12, int(a * 0.15)) + shadow = shadow.filter(ImageFilter.GaussianBlur(radius=int(RENDER * 0.01))) + icon = Image.alpha_composite(icon, shadow) + + # White E glyph + glyph = Image.new("RGBA", (RENDER, RENDER), (0, 0, 0, 0)) + draw_e_rounded(ImageDraw.Draw(glyph), cx, cy, RENDER) + icon = Image.alpha_composite(icon, glyph) + + # Downscale to 1024 + source = icon.resize((FINAL, FINAL), Image.LANCZOS) + src_path = os.path.join(OUT_DIR, "icon-source-1024.png") + source.save(src_path, "PNG") + print(f" Source: {src_path}") + + # ---- Generate all platform sizes from source ---- + print("Generating platform sizes...") + + ios_dir = os.path.join(OUT_DIR, "ios") + android_dir = os.path.join(OUT_DIR, "android") + web_dir = os.path.join(OUT_DIR, "web") + macos_dir = os.path.join(OUT_DIR, "macos") + for d in [ios_dir, android_dir, web_dir, macos_dir]: + os.makedirs(d, exist_ok=True) + + # iOS + ios_sizes = [40, 60, 58, 87, 80, 120, 180, 76, 152, 167, 1024] + for s in ios_sizes: + resized = source.resize((s, s), Image.LANCZOS) + resized.save(os.path.join(ios_dir, f"icon-{s}.png"), "PNG") + print(f" iOS: {len(ios_sizes)} sizes") + + # Write Contents.json for Xcode + contents = { + "images": [ + {"size": "20x20", "idiom": "iphone", "filename": "icon-40.png", "scale": "2x"}, + {"size": "20x20", "idiom": "iphone", "filename": "icon-60.png", "scale": "3x"}, + {"size": "29x29", "idiom": "iphone", "filename": "icon-58.png", "scale": "2x"}, + {"size": "29x29", "idiom": "iphone", "filename": "icon-87.png", "scale": "3x"}, + {"size": "40x40", "idiom": "iphone", "filename": "icon-80.png", "scale": "2x"}, + {"size": "40x40", "idiom": "iphone", "filename": "icon-120.png", "scale": "3x"}, + {"size": "60x60", "idiom": "iphone", "filename": "icon-120.png", "scale": "2x"}, + {"size": "60x60", "idiom": "iphone", "filename": "icon-180.png", "scale": "3x"}, + {"size": "76x76", "idiom": "ipad", "filename": "icon-76.png", "scale": "1x"}, + {"size": "76x76", "idiom": "ipad", "filename": "icon-152.png", "scale": "2x"}, + {"size": "83.5x83.5", "idiom": "ipad", "filename": "icon-167.png", "scale": "2x"}, + {"size": "1024x1024", "idiom": "ios-marketing", "filename": "icon-1024.png", "scale": "1x"}, + ], + "info": {"version": 1, "author": "xcode"}, + } + with open(os.path.join(ios_dir, "Contents.json"), "w") as f: + json.dump(contents, f, indent=2) + + # Android + android_sizes = {"mdpi": 48, "hdpi": 72, "xhdpi": 96, "xxhdpi": 144, "xxxhdpi": 192} + for density, s in android_sizes.items(): + resized = source.resize((s, s), Image.LANCZOS) + resized.save(os.path.join(android_dir, f"ic_launcher_{density}.png"), "PNG") + source.resize((512, 512), Image.LANCZOS).save( + os.path.join(android_dir, "playstore-512.png"), "PNG" + ) + print(f" Android: {len(android_sizes) + 1} sizes") + + # Web + PWA + web_sizes = [16, 32, 48, 72, 96, 128, 144, 152, 192, 384, 512] + for s in web_sizes: + resized = source.resize((s, s), Image.LANCZOS) + resized.save(os.path.join(web_dir, f"icon-{s}.png"), "PNG") + # Apple touch icon + source.resize((180, 180), Image.LANCZOS).save( + os.path.join(web_dir, "apple-touch-icon.png"), "PNG" + ) + # Favicon .ico (multi-size) + ico_16 = source.resize((16, 16), Image.LANCZOS) + ico_32 = source.resize((32, 32), Image.LANCZOS) + ico_48 = source.resize((48, 48), Image.LANCZOS) + ico_16.save(os.path.join(web_dir, "favicon.ico"), format="ICO", sizes=[(16, 16), (32, 32), (48, 48)]) + print(f" Web/PWA: {len(web_sizes) + 2} assets") + + # macOS .iconset (for iconutil → .icns) + iconset_dir = os.path.join(macos_dir, "AppIcon.iconset") + os.makedirs(iconset_dir, exist_ok=True) + macos_sizes = { + "icon_16x16.png": 16, + "icon_16x16@2x.png": 32, + "icon_32x32.png": 32, + "icon_32x32@2x.png": 64, + "icon_128x128.png": 128, + "icon_128x128@2x.png": 256, + "icon_256x256.png": 256, + "icon_256x256@2x.png": 512, + "icon_512x512.png": 512, + "icon_512x512@2x.png": 1024, + } + for name, s in macos_sizes.items(): + resized = source.resize((s, s), Image.LANCZOS) + resized.save(os.path.join(iconset_dir, name), "PNG") + print(f" macOS: {len(macos_sizes)} sizes in AppIcon.iconset") + + print("\nDone! All icons in: generated-icons/") + print("To build macOS .icns: iconutil -c icns generated-icons/macos/AppIcon.iconset") + + +if __name__ == "__main__": + main() diff --git a/scripts/verify_prd_site_rendering.sh b/scripts/verify_prd_site_rendering.sh index 7cc45b6..8e3c4b8 100755 --- a/scripts/verify_prd_site_rendering.sh +++ b/scripts/verify_prd_site_rendering.sh @@ -3,6 +3,3 @@ set -euo pipefail echo "Verifying PRD live sites through the GPUI web surface adapter" cargo test -p ely_app --features live-site-smoke --all-targets -- --test-threads=1 --nocapture - -echo "Verifying PRD live sites through Servo sidecar RGBA snapshots" -cargo test -p ely_servo_host --features servo-engine --test sidecar -- --test-threads=1 --nocapture diff --git a/third_party/gpui/Cargo.toml b/third_party/gpui/Cargo.toml index 9f20281..a482f0f 100644 --- a/third_party/gpui/Cargo.toml +++ b/third_party/gpui/Cargo.toml @@ -167,10 +167,6 @@ version = "0.2" [dependencies.log] version = "0.4.16" -features = [ - "kv_unstable_serde", - "serde", -] [dependencies.lyon] version = "1.0" @@ -499,10 +495,6 @@ version = "0.5" [target.'cfg(target_os = "macos")'.dependencies.log] version = "0.4.16" -features = [ - "kv_unstable_serde", - "serde", -] [target.'cfg(target_os = "macos")'.dependencies.media] version = "0.2.2" diff --git a/third_party/gpui/src/elements/mod.rs b/third_party/gpui/src/elements/mod.rs index bfbc08b..169720e 100644 --- a/third_party/gpui/src/elements/mod.rs +++ b/third_party/gpui/src/elements/mod.rs @@ -6,6 +6,7 @@ mod div; mod image_cache; mod img; mod list; +mod native_surface; mod surface; mod svg; mod text; @@ -19,6 +20,7 @@ pub use div::*; pub use image_cache::*; pub use img::*; pub use list::*; +pub use native_surface::*; pub use surface::*; pub use svg::*; pub use text::*; diff --git a/third_party/gpui/src/elements/native_surface.rs b/third_party/gpui/src/elements/native_surface.rs new file mode 100644 index 0000000..28f9dd3 --- /dev/null +++ b/third_party/gpui/src/elements/native_surface.rs @@ -0,0 +1,109 @@ +use crate::{ + App, Bounds, Element, ElementId, GlobalElementId, InspectorElementId, IntoElement, LayoutId, + NativeSurfaceHandle, Pixels, Style, StyleRefinement, Styled, Window, +}; +use refineable::Refineable; + +/// A native platform surface hosted inside a GPUI layout box. +pub struct NativeSurface { + id: ElementId, + on_surface: Option, &mut Window, &mut App)>>, + style: StyleRefinement, +} + +/// Create a native platform surface element. +pub fn native_surface( + id: impl Into, + on_surface: impl FnMut(NativeSurfaceHandle, Bounds, &mut Window, &mut App) + 'static, +) -> NativeSurface { + NativeSurface { id: id.into(), on_surface: Some(Box::new(on_surface)), style: Default::default() } +} + +impl Element for NativeSurface { + type RequestLayoutState = (); + type PrepaintState = (); + + fn id(&self) -> Option { + Some(self.id.clone()) + } + + fn source_location(&self) -> Option<&'static core::panic::Location<'static>> { + None + } + + fn request_layout( + &mut self, + _global_id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (LayoutId, Self::RequestLayoutState) { + let mut style = Style::default(); + style.refine(&self.style); + let layout_id = window.request_layout(style, [], cx); + (layout_id, ()) + } + + fn prepaint( + &mut self, + _global_id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + _bounds: Bounds, + _request_layout: &mut Self::RequestLayoutState, + _window: &mut Window, + _cx: &mut App, + ) -> Self::PrepaintState { + } + + fn paint( + &mut self, + global_id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + bounds: Bounds, + _: &mut Self::RequestLayoutState, + _: &mut Self::PrepaintState, + window: &mut Window, + cx: &mut App, + ) { + let Some(global_id) = global_id else { + return; + }; + let Some(on_surface) = self.on_surface.as_mut() else { + return; + }; + let Some(surface) = window.with_element_state::( + global_id, + |state, window| { + let surface = state + .and_then(|state| state.surface) + .or_else(|| window.create_native_surface()); + if let Some(surface) = surface { + window.sync_native_surface(&surface, bounds); + return (Some(surface.clone()), NativeSurfaceState { surface: Some(surface) }); + } + (None, NativeSurfaceState { surface: None }) + }, + ) else { + return; + }; + on_surface(surface, bounds, window, cx); + } +} + +impl IntoElement for NativeSurface { + type Element = Self; + + fn into_element(self) -> Self::Element { + self + } +} + +impl Styled for NativeSurface { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} + +struct NativeSurfaceState { + surface: Option, +} diff --git a/third_party/gpui/src/platform.rs b/third_party/gpui/src/platform.rs index 047a005..1f89980 100644 --- a/third_party/gpui/src/platform.rs +++ b/third_party/gpui/src/platform.rs @@ -48,15 +48,25 @@ use async_task::Runnable; use futures::channel::oneshot; use image::codecs::gif::GifDecoder; use image::{AnimationDecoder as _, Frame}; -use raw_window_handle::{HasDisplayHandle, HasWindowHandle}; +#[cfg(target_os = "macos")] +use objc::sel; +#[cfg(target_os = "macos")] +use objc::sel_impl; +use raw_window_handle::{HandleError, HasDisplayHandle, HasWindowHandle}; use schemars::JsonSchema; use seahash::SeaHasher; use serde::{Deserialize, Serialize}; use smallvec::SmallVec; use std::borrow::Cow; +#[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux", target_os = "freebsd"))] +use std::ffi::c_void; use std::hash::{Hash, Hasher}; use std::io::Cursor; +#[cfg(target_os = "windows")] +use std::num::NonZeroIsize; use std::ops; +#[cfg(any(target_os = "macos", target_os = "linux", target_os = "freebsd"))] +use std::ptr::NonNull; use std::time::{Duration, Instant}; use std::{ fmt::{self, Debug}, @@ -457,6 +467,240 @@ pub(crate) struct RequestFrameOptions { pub(crate) force_render: bool, } +/// A platform child surface that can be handed to an embedded renderer. +#[derive(Clone, Debug)] +pub struct NativeSurfaceHandle { + inner: Arc, +} + +#[derive(Debug)] +enum NativeSurfaceHandleInner { + #[cfg(target_os = "macos")] + MacOS { appkit_ns_view: NonNull }, + #[cfg(target_os = "windows")] + Windows { hwnd: isize }, + #[cfg(all(any(target_os = "linux", target_os = "freebsd"), feature = "x11"))] + X11 { + connection: usize, + screen_id: i32, + window_id: u32, + visual_id: u32, + }, + #[cfg(all(any(target_os = "linux", target_os = "freebsd"), feature = "wayland"))] + Wayland { surface: usize, display: usize }, +} + +impl NativeSurfaceHandle { + #[cfg(target_os = "macos")] + pub(crate) fn from_appkit_ns_view(appkit_ns_view: NonNull) -> Self { + Self { inner: Arc::new(NativeSurfaceHandleInner::MacOS { appkit_ns_view }) } + } + + #[cfg(target_os = "windows")] + pub(crate) fn from_win32_hwnd(hwnd: isize) -> Self { + Self { inner: Arc::new(NativeSurfaceHandleInner::Windows { hwnd }) } + } + + #[cfg(all(any(target_os = "linux", target_os = "freebsd"), feature = "x11"))] + pub(crate) fn from_xcb_window( + connection: usize, + screen_id: i32, + window_id: u32, + visual_id: u32, + ) -> Self { + Self { + inner: Arc::new(NativeSurfaceHandleInner::X11 { + connection, + screen_id, + window_id, + visual_id, + }), + } + } + + #[cfg(all(any(target_os = "linux", target_os = "freebsd"), feature = "wayland"))] + pub(crate) fn from_wayland_surface(surface: usize, display: usize) -> Self { + Self { inner: Arc::new(NativeSurfaceHandleInner::Wayland { surface, display }) } + } + + /// Returns the backing `NSView` pointer on macOS. + #[cfg(target_os = "macos")] + #[must_use] + pub fn appkit_ns_view(&self) -> NonNull { + match self.inner.as_ref() { + NativeSurfaceHandleInner::MacOS { appkit_ns_view } => *appkit_ns_view, + } + } + + /// Returns the backing Win32 `HWND` value on Windows. + #[cfg(target_os = "windows")] + #[must_use] + pub fn win32_hwnd(&self) -> isize { + match self.inner.as_ref() { + NativeSurfaceHandleInner::Windows { hwnd } => *hwnd, + } + } + + /// Returns the backing XCB window id on X11. + #[cfg(all(any(target_os = "linux", target_os = "freebsd"), feature = "x11"))] + #[must_use] + pub(crate) fn xcb_window_id(&self) -> u32 { + match self.inner.as_ref() { + NativeSurfaceHandleInner::X11 { window_id, .. } => *window_id, + #[cfg(feature = "wayland")] + NativeSurfaceHandleInner::Wayland { .. } => 0, + } + } + + /// Returns a stable identity for the lifetime of this native surface. + #[must_use] + pub fn identity(&self) -> usize { + match self.inner.as_ref() { + #[cfg(target_os = "macos")] + NativeSurfaceHandleInner::MacOS { appkit_ns_view } => appkit_ns_view.as_ptr() as usize, + #[cfg(target_os = "windows")] + NativeSurfaceHandleInner::Windows { hwnd } => *hwnd as usize, + #[cfg(all(any(target_os = "linux", target_os = "freebsd"), feature = "x11"))] + NativeSurfaceHandleInner::X11 { window_id, .. } => *window_id as usize, + #[cfg(all(any(target_os = "linux", target_os = "freebsd"), feature = "wayland"))] + NativeSurfaceHandleInner::Wayland { surface, .. } => *surface, + } + } +} + +impl Drop for NativeSurfaceHandleInner { + fn drop(&mut self) { + match self { + #[cfg(target_os = "macos")] + Self::MacOS { appkit_ns_view } => unsafe { + let view = appkit_ns_view.as_ptr() as *mut objc::runtime::Object; + let _: () = objc::msg_send![view, removeFromSuperview]; + let _: () = objc::msg_send![view, release]; + }, + #[cfg(target_os = "windows")] + Self::Windows { hwnd } => unsafe { + let _ = ::windows::Win32::UI::WindowsAndMessaging::DestroyWindow( + ::windows::Win32::Foundation::HWND(*hwnd as *mut c_void), + ); + }, + #[cfg(all(any(target_os = "linux", target_os = "freebsd"), feature = "x11"))] + Self::X11 { connection, window_id, .. } => { + if let Some(connection) = NonNull::new(*connection as *mut c_void) { + use x11rb::connection::Connection as _; + use x11rb::protocol::xproto::ConnectionExt as _; + + if let Ok(xcb) = unsafe { + x11rb::xcb_ffi::XCBConnection::from_raw_xcb_connection( + connection.as_ptr(), + false, + ) + } { + let _ = xcb.destroy_window(*window_id); + let _ = xcb.flush(); + } + } + } + #[cfg(all(any(target_os = "linux", target_os = "freebsd"), feature = "wayland"))] + Self::Wayland { .. } => {} + } + } +} + +unsafe impl Send for NativeSurfaceHandle {} + +unsafe impl Sync for NativeSurfaceHandle {} + +impl HasWindowHandle for NativeSurfaceHandle { + fn window_handle(&self) -> Result, HandleError> { + match self.inner.as_ref() { + #[cfg(target_os = "macos")] + NativeSurfaceHandleInner::MacOS { appkit_ns_view } => unsafe { + Ok(raw_window_handle::WindowHandle::borrow_raw( + raw_window_handle::RawWindowHandle::AppKit( + raw_window_handle::AppKitWindowHandle::new(*appkit_ns_view), + ), + )) + }, + #[cfg(target_os = "windows")] + NativeSurfaceHandleInner::Windows { hwnd } => { + let Some(hwnd) = NonZeroIsize::new(*hwnd) else { + return Err(HandleError::Unavailable); + }; + let handle = raw_window_handle::Win32WindowHandle::new(hwnd); + unsafe { + Ok(raw_window_handle::WindowHandle::borrow_raw( + raw_window_handle::RawWindowHandle::Win32(handle), + )) + } + } + #[cfg(all(any(target_os = "linux", target_os = "freebsd"), feature = "x11"))] + NativeSurfaceHandleInner::X11 { window_id, visual_id, .. } => { + let Some(window_id) = std::num::NonZeroU32::new(*window_id) else { + return Err(HandleError::Unavailable); + }; + let mut handle = raw_window_handle::XcbWindowHandle::new(window_id); + handle.visual_id = std::num::NonZeroU32::new(*visual_id); + unsafe { + Ok(raw_window_handle::WindowHandle::borrow_raw( + raw_window_handle::RawWindowHandle::Xcb(handle), + )) + } + } + #[cfg(all(any(target_os = "linux", target_os = "freebsd"), feature = "wayland"))] + NativeSurfaceHandleInner::Wayland { surface, .. } => { + let Some(surface) = NonNull::new(*surface as *mut c_void) else { + return Err(HandleError::Unavailable); + }; + let handle = raw_window_handle::WaylandWindowHandle::new(surface); + unsafe { + Ok(raw_window_handle::WindowHandle::borrow_raw( + raw_window_handle::RawWindowHandle::Wayland(handle), + )) + } + } + } + } +} + +impl HasDisplayHandle for NativeSurfaceHandle { + fn display_handle(&self) -> Result, HandleError> { + match self.inner.as_ref() { + #[cfg(target_os = "macos")] + NativeSurfaceHandleInner::MacOS { .. } => unsafe { + Ok(raw_window_handle::DisplayHandle::borrow_raw( + raw_window_handle::AppKitDisplayHandle::new().into(), + )) + }, + #[cfg(target_os = "windows")] + NativeSurfaceHandleInner::Windows { .. } => Ok(raw_window_handle::DisplayHandle::windows()), + #[cfg(all(any(target_os = "linux", target_os = "freebsd"), feature = "x11"))] + NativeSurfaceHandleInner::X11 { connection, screen_id, .. } => { + let Some(connection) = NonNull::new(*connection as *mut c_void) else { + return Err(HandleError::Unavailable); + }; + let handle = raw_window_handle::XcbDisplayHandle::new(Some(connection), *screen_id); + unsafe { + Ok(raw_window_handle::DisplayHandle::borrow_raw( + raw_window_handle::RawDisplayHandle::Xcb(handle), + )) + } + } + #[cfg(all(any(target_os = "linux", target_os = "freebsd"), feature = "wayland"))] + NativeSurfaceHandleInner::Wayland { display, .. } => { + let Some(display) = NonNull::new(*display as *mut c_void) else { + return Err(HandleError::Unavailable); + }; + let handle = raw_window_handle::WaylandDisplayHandle::new(display); + unsafe { + Ok(raw_window_handle::DisplayHandle::borrow_raw( + raw_window_handle::RawDisplayHandle::Wayland(handle), + )) + } + } + } + } +} + pub(crate) trait PlatformWindow: HasWindowHandle + HasDisplayHandle { fn bounds(&self) -> Bounds; fn is_maximized(&self) -> bool; @@ -500,6 +744,10 @@ pub(crate) trait PlatformWindow: HasWindowHandle + HasDisplayHandle { fn draw(&self, scene: &Scene); fn completed_frame(&self) {} fn sprite_atlas(&self) -> Arc; + fn create_native_surface(&self) -> Option { + None + } + fn sync_native_surface(&self, _surface: &NativeSurfaceHandle, _bounds: Bounds) {} // macOS specific methods fn get_title(&self) -> String { diff --git a/third_party/gpui/src/platform/linux/wayland/client.rs b/third_party/gpui/src/platform/linux/wayland/client.rs index 1ebdda3..a762201 100644 --- a/third_party/gpui/src/platform/linux/wayland/client.rs +++ b/third_party/gpui/src/platform/linux/wayland/client.rs @@ -33,7 +33,7 @@ use wayland_client::{ Connection, Dispatch, Proxy, QueueHandle, delegate_noop, protocol::{ wl_buffer, wl_compositor, wl_keyboard, wl_pointer, wl_registry, wl_seat, wl_shm, - wl_shm_pool, wl_surface, + wl_shm_pool, wl_subcompositor, wl_subsurface, wl_surface, }, }; use wayland_protocols::wp::cursor_shape::v1::client::{ @@ -111,6 +111,7 @@ pub struct Globals { pub wm_base: xdg_wm_base::XdgWmBase, pub shm: wl_shm::WlShm, pub seat: wl_seat::WlSeat, + pub subcompositor: Option, pub viewporter: Option, pub fractional_scale_manager: Option, @@ -149,6 +150,7 @@ impl Globals { shm: globals.bind(&qh, 1..=1, ()).unwrap(), seat, wm_base: globals.bind(&qh, 2..=5, ()).unwrap(), + subcompositor: globals.bind(&qh, 1..=1, ()).ok(), viewporter: globals.bind(&qh, 1..=1, ()).ok(), fractional_scale_manager: globals.bind(&qh, 1..=1, ()).ok(), decoration_manager: globals.bind(&qh, 1..=1, ()).ok(), @@ -943,6 +945,8 @@ delegate_noop!(WaylandClientStatePtr: ignore wl_shm::WlShm); delegate_noop!(WaylandClientStatePtr: ignore wl_shm_pool::WlShmPool); delegate_noop!(WaylandClientStatePtr: ignore wl_buffer::WlBuffer); delegate_noop!(WaylandClientStatePtr: ignore wl_region::WlRegion); +delegate_noop!(WaylandClientStatePtr: wl_subcompositor::WlSubcompositor); +delegate_noop!(WaylandClientStatePtr: wl_subsurface::WlSubsurface); delegate_noop!(WaylandClientStatePtr: ignore wp_fractional_scale_manager_v1::WpFractionalScaleManagerV1); delegate_noop!(WaylandClientStatePtr: ignore zxdg_decoration_manager_v1::ZxdgDecorationManagerV1); delegate_noop!(WaylandClientStatePtr: ignore org_kde_kwin_blur_manager::OrgKdeKwinBlurManager); diff --git a/third_party/gpui/src/platform/linux/wayland/window.rs b/third_party/gpui/src/platform/linux/wayland/window.rs index aa3b714..0ed2112 100644 --- a/third_party/gpui/src/platform/linux/wayland/window.rs +++ b/third_party/gpui/src/platform/linux/wayland/window.rs @@ -13,7 +13,10 @@ use futures::channel::oneshot::Receiver; use raw_window_handle as rwh; use wayland_backend::client::ObjectId; use wayland_client::WEnum; -use wayland_client::{Proxy, protocol::wl_surface}; +use wayland_client::{ + Proxy, + protocol::{wl_subsurface, wl_surface}, +}; use wayland_protocols::wp::viewporter::client::wp_viewport; use wayland_protocols::xdg::decoration::zv1::client::zxdg_toplevel_decoration_v1; use wayland_protocols::xdg::shell::client::xdg_surface; @@ -92,6 +95,7 @@ pub struct WaylandWindowState { blur: Option, toplevel: xdg_toplevel::XdgToplevel, viewport: Option, + native_surfaces: HashMap, outputs: HashMap, display: Option<(ObjectId, Output)>, globals: Globals, @@ -166,6 +170,7 @@ impl WaylandWindowState { blur: None, toplevel, viewport, + native_surfaces: HashMap::default(), globals, outputs: HashMap::default(), display: None, @@ -222,6 +227,22 @@ impl WaylandWindowState { } } +struct WaylandNativeSurface { + surface: wl_surface::WlSurface, + subsurface: wl_subsurface::WlSubsurface, + viewport: Option, +} + +impl WaylandNativeSurface { + fn destroy(self) { + if let Some(viewport) = self.viewport { + viewport.destroy(); + } + self.subsurface.destroy(); + self.surface.destroy(); + } +} + pub(crate) struct WaylandWindow(pub WaylandWindowStatePtr); pub enum ImeInput { InsertText(String), @@ -247,6 +268,9 @@ impl Drop for WaylandWindow { if let Some(viewport) = &state.viewport { viewport.destroy(); } + for (_, native_surface) in state.native_surfaces.drain() { + native_surface.destroy(); + } state.xdg_surface.destroy(); state.surface.destroy(); @@ -1032,6 +1056,52 @@ impl PlatformWindow for WaylandWindow { state.renderer.sprite_atlas().clone() } + fn create_native_surface(&self) -> Option { + let mut state = self.borrow_mut(); + let subcompositor = state.globals.subcompositor.as_ref()?; + let surface = state.globals.compositor.create_surface(&state.globals.qh, ()); + let subsurface = + subcompositor.get_subsurface(&surface, &state.surface, &state.globals.qh, ()); + subsurface.set_desync(); + let viewport = state + .globals + .viewporter + .as_ref() + .map(|viewporter| viewporter.get_viewport(&surface, &state.globals.qh, ())); + let display = surface.backend().upgrade()?.display_ptr().cast::() as usize; + let surface_id = surface.id().as_ptr().cast::() as usize; + surface.commit(); + state.native_surfaces.insert( + surface_id, + WaylandNativeSurface { + surface, + subsurface, + viewport, + }, + ); + Some(crate::NativeSurfaceHandle::from_wayland_surface( + surface_id, display, + )) + } + + fn sync_native_surface(&self, surface: &crate::NativeSurfaceHandle, bounds: Bounds) { + let state = self.borrow(); + let bounds = bounds.to_device_pixels(state.scale); + if let Some(native_surface) = state.native_surfaces.get(&surface.identity()) { + native_surface + .subsurface + .set_position(bounds.origin.x.0, bounds.origin.y.0); + if let Some(viewport) = native_surface.viewport.as_ref() { + viewport.set_destination( + bounds.size.width.0.max(1), + bounds.size.height.0.max(1), + ); + } + native_surface.surface.commit(); + state.surface.commit(); + } + } + fn show_window_menu(&self, position: Point) { let state = self.borrow(); let serial = state.client.get_serial(SerialKind::MousePress); diff --git a/third_party/gpui/src/platform/linux/x11/window.rs b/third_party/gpui/src/platform/linux/x11/window.rs index fe197a6..0cc1e61 100644 --- a/third_party/gpui/src/platform/linux/x11/window.rs +++ b/third_party/gpui/src/platform/linux/x11/window.rs @@ -4,10 +4,11 @@ use x11rb::connection::RequestConnection; use crate::platform::blade::{BladeContext, BladeRenderer, BladeSurfaceConfig}; use crate::{ AnyWindowHandle, Bounds, Decorations, DevicePixels, ForegroundExecutor, GpuSpecs, Modifiers, - Pixels, PlatformAtlas, PlatformDisplay, PlatformInput, PlatformInputHandler, PlatformWindow, - Point, PromptButton, PromptLevel, RequestFrameOptions, ResizeEdge, ScaledPixels, Scene, Size, - Tiling, WindowAppearance, WindowBackgroundAppearance, WindowBounds, WindowControlArea, - WindowDecorations, WindowKind, WindowParams, X11ClientStatePtr, px, size, + NativeSurfaceHandle, Pixels, PlatformAtlas, PlatformDisplay, PlatformInput, + PlatformInputHandler, PlatformWindow, Point, PromptButton, PromptLevel, RequestFrameOptions, + ResizeEdge, ScaledPixels, Scene, Size, Tiling, WindowAppearance, WindowBackgroundAppearance, + WindowBounds, WindowControlArea, WindowDecorations, WindowKind, WindowParams, + X11ClientStatePtr, px, size, }; use blade_graphics as gpu; @@ -253,6 +254,8 @@ pub struct X11WindowState { executor: ForegroundExecutor, atoms: XcbAtoms, x_root_window: xproto::Window, + x_screen_id: usize, + x_visual_id: u32, pub(crate) counter_id: sync::Counter, pub(crate) last_sync_counter: Option, bounds: Bounds, @@ -671,6 +674,8 @@ impl X11WindowState { executor, display, x_root_window: visual_set.root, + x_screen_id: x_screen_index, + x_visual_id: visual.id, bounds: bounds.to_pixels(scale_factor), scale_factor, renderer, @@ -1480,6 +1485,61 @@ impl PlatformWindow for X11Window { inner.renderer.sprite_atlas().clone() } + fn create_native_surface(&self) -> Option { + let state = self.0.state.borrow(); + let window_id = self.0.xcb.generate_id().log_err()?; + let aux = xproto::CreateWindowAux::new().event_mask(xproto::EventMask::NO_EVENT); + check_reply( + || "X11 CreateWindow failed for native surface.", + self.0.xcb.create_window( + 0, + window_id, + self.0.x_window, + 0, + 0, + 1, + 1, + 0, + xproto::WindowClass::INPUT_OUTPUT, + 0, + &aux, + ), + ) + .log_err()?; + check_reply( + || "X11 MapWindow failed for native surface.", + self.0.xcb.map_window(window_id), + ) + .log_err()?; + xcb_flush(&self.0.xcb); + Some(NativeSurfaceHandle::from_xcb_window( + as_raw_xcb_connection::AsRawXcbConnection::as_raw_xcb_connection(&*self.0.xcb) + as usize, + state.x_screen_id as i32, + window_id, + state.x_visual_id, + )) + } + + fn sync_native_surface(&self, surface: &NativeSurfaceHandle, bounds: Bounds) { + let state = self.0.state.borrow(); + let bounds = bounds.to_device_pixels(state.scale_factor); + drop(state); + check_reply( + || "X11 ConfigureWindow failed for native surface.", + self.0.xcb.configure_window( + surface.xcb_window_id(), + &xproto::ConfigureWindowAux::new() + .x(bounds.origin.x.0) + .y(bounds.origin.y.0) + .width(bounds.size.width.0.max(1) as u32) + .height(bounds.size.height.0.max(1) as u32), + ), + ) + .log_err(); + xcb_flush(&self.0.xcb); + } + fn show_window_menu(&self, position: Point) { let state = self.0.state.borrow(); diff --git a/third_party/gpui/src/platform/mac/window.rs b/third_party/gpui/src/platform/mac/window.rs index 708783f..c2f31af 100644 --- a/third_party/gpui/src/platform/mac/window.rs +++ b/third_party/gpui/src/platform/mac/window.rs @@ -2,11 +2,12 @@ use super::{BoolExt, MacDisplay, NSRange, NSStringExt, ns_string, renderer}; use crate::{ AnyWindowHandle, Bounds, Capslock, DisplayLink, ExternalPaths, FileDropEvent, ForegroundExecutor, KeyDownEvent, Keystroke, Modifiers, ModifiersChangedEvent, MouseButton, - MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, PlatformAtlas, PlatformDisplay, - PlatformInput, PlatformWindow, Point, PromptButton, PromptLevel, RequestFrameOptions, - SharedString, Size, SystemWindowTab, Timer, WindowAppearance, WindowBackgroundAppearance, - WindowBounds, WindowControlArea, WindowKind, WindowParams, dispatch_get_main_queue, - dispatch_sys::dispatch_async_f, platform::PlatformInputHandler, point, px, size, + MouseDownEvent, MouseMoveEvent, MouseUpEvent, NativeSurfaceHandle, Pixels, PlatformAtlas, + PlatformDisplay, PlatformInput, PlatformWindow, Point, PromptButton, PromptLevel, + RequestFrameOptions, SharedString, Size, SystemWindowTab, Timer, WindowAppearance, + WindowBackgroundAppearance, WindowBounds, WindowControlArea, WindowKind, WindowParams, + dispatch_get_main_queue, dispatch_sys::dispatch_async_f, platform::PlatformInputHandler, point, + px, size, }; use block::ConcreteBlock; use cocoa::{ @@ -57,6 +58,7 @@ const WINDOW_STATE_IVAR: &str = "windowState"; static mut WINDOW_CLASS: *const Class = ptr::null(); static mut PANEL_CLASS: *const Class = ptr::null(); static mut VIEW_CLASS: *const Class = ptr::null(); +static mut NATIVE_SURFACE_VIEW_CLASS: *const Class = ptr::null(); static mut BLURRED_VIEW_CLASS: *const Class = ptr::null(); #[allow(non_upper_case_globals)] @@ -252,6 +254,16 @@ unsafe fn build_classes() { } decl.register() }; + NATIVE_SURFACE_VIEW_CLASS = { + let mut decl = ClassDecl::new("GPUINativeSurfaceView", class!(NSView)).unwrap(); + unsafe { + decl.add_method( + sel!(hitTest:), + native_surface_hit_test as extern "C" fn(&Object, Sel, NSPoint) -> id, + ); + decl.register() + } + }; BLURRED_VIEW_CLASS = { let mut decl = ClassDecl::new("BlurredView", class!(NSVisualEffectView)).unwrap(); unsafe { @@ -1504,6 +1516,45 @@ impl PlatformWindow for MacWindow { self.0.lock().renderer.sprite_atlas().clone() } + fn create_native_surface(&self) -> Option { + let this = self.0.lock(); + unsafe { + let parent = this.native_view.as_ptr() as id; + let native_surface: id = msg_send![NATIVE_SURFACE_VIEW_CLASS, alloc]; + let native_surface = NSView::initWithFrame_( + native_surface, + NSRect::new(NSPoint::new(0.0, 0.0), NSSize::new(1.0, 1.0)), + ); + if native_surface.is_null() { + return None; + } + native_surface.setWantsBestResolutionOpenGLSurface_(YES); + native_surface.setWantsLayer(YES); + let _: () = msg_send![ + native_surface, + setLayerContentsRedrawPolicy: NSViewLayerContentsRedrawDuringViewResize + ]; + let _: () = msg_send![parent, addSubview: native_surface]; + NonNull::new(native_surface as *mut c_void).map(NativeSurfaceHandle::from_appkit_ns_view) + } + } + + fn sync_native_surface(&self, surface: &NativeSurfaceHandle, bounds: Bounds) { + let this = self.0.lock(); + unsafe { + let parent = this.native_view.as_ptr() as id; + let parent_frame: NSRect = msg_send![parent, frame]; + let view = surface.appkit_ns_view().as_ptr() as id; + let width = bounds.size.width.0.max(1.0) as f64; + let height = bounds.size.height.0.max(1.0) as f64; + let x = bounds.origin.x.0 as f64; + let y = (parent_frame.size.height - bounds.origin.y.0 as f64 - height).max(0.0); + let frame = NSRect::new(NSPoint::new(x, y), NSSize::new(width, height)); + let _: () = msg_send![view, setFrame: frame]; + let _: () = msg_send![view, setHidden: NO]; + } + } + fn gpu_specs(&self) -> Option { None } @@ -1632,6 +1683,10 @@ extern "C" fn yes(_: &Object, _: Sel) -> BOOL { YES } +extern "C" fn native_surface_hit_test(_: &Object, _: Sel, _: NSPoint) -> id { + nil +} + extern "C" fn dealloc_window(this: &Object, _: Sel) { unsafe { drop_window_state(this); diff --git a/third_party/gpui/src/platform/windows/window.rs b/third_party/gpui/src/platform/windows/window.rs index e765fa1..c1d339a 100644 --- a/third_party/gpui/src/platform/windows/window.rs +++ b/third_party/gpui/src/platform/windows/window.rs @@ -849,6 +849,46 @@ impl PlatformWindow for WindowsWindow { self.0.state.borrow().renderer.sprite_atlas() } + fn create_native_surface(&self) -> Option { + register_native_surface_window_class(); + let hwnd = unsafe { + CreateWindowExW( + WS_EX_NOACTIVATE, + NATIVE_SURFACE_CLASS_NAME, + w!(""), + WS_CHILD | WS_VISIBLE | WS_CLIPSIBLINGS | WS_CLIPCHILDREN | WS_DISABLED, + 0, + 0, + 1, + 1, + Some(self.0.hwnd), + None, + Some(get_module_handle().into()), + None, + ) + } + .ok()?; + Some(NativeSurfaceHandle::from_win32_hwnd(hwnd.0 as isize)) + } + + fn sync_native_surface(&self, surface: &NativeSurfaceHandle, bounds: Bounds) { + let bounds = bounds.to_device_pixels(self.scale_factor()); + let hwnd = HWND(surface.win32_hwnd() as _); + unsafe { + SetWindowPos( + hwnd, + None, + bounds.origin.x.0, + bounds.origin.y.0, + bounds.size.width.0.max(1), + bounds.size.height.0.max(1), + SWP_NOZORDER | SWP_NOACTIVATE, + ) + .log_err(); + ShowWindow(hwnd, SW_SHOW).ok().log_err(); + } + } + fn get_raw_handle(&self) -> HWND { self.0.hwnd } @@ -1145,6 +1185,7 @@ enum WindowOpenState { } const WINDOW_CLASS_NAME: PCWSTR = w!("Zed::Window"); +const NATIVE_SURFACE_CLASS_NAME: PCWSTR = w!("Zed::NativeSurface"); fn register_window_class(icon_handle: HICON) { static ONCE: Once = Once::new(); @@ -1162,6 +1203,30 @@ fn register_window_class(icon_handle: HICON) { }); } +fn register_native_surface_window_class() { + static ONCE: Once = Once::new(); + ONCE.call_once(|| { + let wc = WNDCLASSW { + lpfnWndProc: Some(native_surface_window_procedure), + lpszClassName: PCWSTR(NATIVE_SURFACE_CLASS_NAME.as_ptr()), + style: CS_OWNDC | CS_HREDRAW | CS_VREDRAW, + hInstance: get_module_handle().into(), + hbrBackground: unsafe { CreateSolidBrush(COLORREF(0x00000000)) }, + ..Default::default() + }; + unsafe { RegisterClassW(&wc) }; + }); +} + +unsafe extern "system" fn native_surface_window_procedure( + hwnd: HWND, + msg: u32, + wparam: WPARAM, + lparam: LPARAM, +) -> LRESULT { + unsafe { DefWindowProcW(hwnd, msg, wparam, lparam) } +} + unsafe extern "system" fn window_procedure( hwnd: HWND, msg: u32, diff --git a/third_party/gpui/src/window.rs b/third_party/gpui/src/window.rs index 92835a1..e55f2c3 100644 --- a/third_party/gpui/src/window.rs +++ b/third_party/gpui/src/window.rs @@ -10,13 +10,14 @@ use crate::{ LineLayoutIndex, Modifiers, ModifiersChangedEvent, MonochromeSprite, MouseButton, MouseEvent, MouseMoveEvent, MouseUpEvent, Path, Pixels, PlatformAtlas, PlatformDisplay, PlatformInput, PlatformInputHandler, PlatformWindow, Point, PolychromeSprite, PromptButton, PromptLevel, Quad, - Render, RenderGlyphParams, RenderImage, RenderImageParams, RenderSvgParams, Replay, ResizeEdge, - SMOOTH_SVG_SCALE_FACTOR, SUBPIXEL_VARIANTS_X, SUBPIXEL_VARIANTS_Y, ScaledPixels, Scene, Shadow, - SharedString, Size, StrikethroughStyle, Style, SubscriberSet, Subscription, SystemWindowTab, - SystemWindowTabController, TabStopMap, TaffyLayoutEngine, Task, TextStyle, TextStyleRefinement, - TransformationMatrix, Underline, UnderlineStyle, WindowAppearance, WindowBackgroundAppearance, - WindowBounds, WindowControls, WindowDecorations, WindowOptions, WindowParams, WindowTextSystem, - point, prelude::*, px, rems, size, transparent_black, + NativeSurfaceHandle, Render, RenderGlyphParams, RenderImage, RenderImageParams, + RenderSvgParams, Replay, ResizeEdge, SMOOTH_SVG_SCALE_FACTOR, SUBPIXEL_VARIANTS_X, + SUBPIXEL_VARIANTS_Y, ScaledPixels, Scene, Shadow, SharedString, Size, StrikethroughStyle, + Style, SubscriberSet, Subscription, SystemWindowTab, SystemWindowTabController, TabStopMap, + TaffyLayoutEngine, Task, TextStyle, TextStyleRefinement, TransformationMatrix, Underline, + UnderlineStyle, WindowAppearance, WindowBackgroundAppearance, WindowBounds, WindowControls, + WindowDecorations, WindowOptions, WindowParams, WindowTextSystem, point, prelude::*, px, rems, + size, transparent_black, }; use anyhow::{Context as _, Result, anyhow}; use collections::{FxHashMap, FxHashSet}; @@ -3201,6 +3202,16 @@ impl Window { }); } + /// Creates a native child surface hosted by this window. + pub fn create_native_surface(&self) -> Option { + self.platform_window.create_native_surface() + } + + /// Synchronizes a native child surface to a GPUI layout box. + pub fn sync_native_surface(&self, surface: &NativeSurfaceHandle, bounds: Bounds) { + self.platform_window.sync_native_surface(surface, bounds); + } + /// Removes an image from the sprite atlas. pub fn drop_image(&mut self, data: Arc) -> Result<()> { for frame_index in 0..data.frame_count() { diff --git a/third_party/gpui_support/gpui_http_client/Cargo.toml b/third_party/gpui_support/gpui_http_client/Cargo.toml new file mode 100644 index 0000000..0a3efdb --- /dev/null +++ b/third_party/gpui_support/gpui_http_client/Cargo.toml @@ -0,0 +1,133 @@ +# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO +# +# When uploading crates to the registry Cargo will automatically +# "normalize" Cargo.toml files for maximal compatibility +# with all versions of Cargo and also rewrite `path` dependencies +# to registry (e.g., crates.io) dependencies. +# +# Local copy keeps the registry manifest shape and narrows log features so +# GPUI and Servo can share the same workspace dependency graph. + +[package] +edition = "2024" +name = "gpui_http_client" +version = "0.2.2" +build = false +publish = true +autolib = false +autobins = false +autoexamples = false +autotests = false +autobenches = false +description = "A HTTP client library for Zed and GPUI" +readme = false +license = "Apache-2.0" +resolver = "2" + +[features] +test-support = [] + +[lib] +name = "gpui_http_client" +path = "src/http_client.rs" +doctest = true + +[dependencies.anyhow] +version = "1.0.86" + +[dependencies.async-compression] +version = "0.4" +features = [ + "gzip", + "futures-io", +] + +[dependencies.async-fs] +version = "2.1" + +[dependencies.async-tar] +version = "0.5.0-zed" +package = "zed-async-tar" + +[dependencies.bytes] +version = "1.0" + +[dependencies.derive_more] +version = "0.99.17" + +[dependencies.futures] +version = "0.3" + +[dependencies.http] +version = "1.1" + +[dependencies.http-body] +version = "1.0" + +[dependencies.log] +version = "0.4.16" + +[dependencies.parking_lot] +version = "0.12.1" + +[dependencies.reqwest] +version = "0.12.15-zed" +features = [ + "charset", + "http2", + "macos-system-configuration", + "multipart", + "rustls-tls-native-roots", + "socks", + "stream", +] +default-features = false +package = "zed-reqwest" + +[dependencies.serde] +version = "1.0.221" +features = [ + "derive", + "rc", +] + +[dependencies.serde_json] +version = "1.0.144" +features = [ + "preserve_order", + "raw_value", +] + +[dependencies.sha2] +version = "0.10" + +[dependencies.tempfile] +version = "3.20.0" + +[dependencies.url] +version = "2.2" + +[dependencies.util] +version = "0.2.2" +package = "gpui_util" + +[lints.clippy] +dbg_macro = "deny" +declare_interior_mutable_const = "deny" +disallowed_methods = "deny" +large_enum_variant = "allow" +let_underscore_future = "allow" +nonminimal_bool = "allow" +redundant_clone = "deny" +single_range_in_vec_init = "allow" +todo = "deny" +too_many_arguments = "allow" +type_complexity = "allow" + +[lints.clippy.style] +level = "allow" +priority = -1 + +[lints.rust.unexpected_cfgs] +level = "allow" +priority = 0 diff --git a/third_party/gpui_support/gpui_http_client/LICENSE-APACHE b/third_party/gpui_support/gpui_http_client/LICENSE-APACHE new file mode 100644 index 0000000..461a0fe --- /dev/null +++ b/third_party/gpui_support/gpui_http_client/LICENSE-APACHE @@ -0,0 +1,222 @@ +Copyright 2022 - 2025 Zed Industries, Inc. + + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + + http://www.apache.org/licenses/LICENSE-2.0 + + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + + + +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + + 1. Definitions. + + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + + END OF TERMS AND CONDITIONS diff --git a/third_party/gpui_support/gpui_http_client/src/async_body.rs b/third_party/gpui_support/gpui_http_client/src/async_body.rs new file mode 100644 index 0000000..6b99a54 --- /dev/null +++ b/third_party/gpui_support/gpui_http_client/src/async_body.rs @@ -0,0 +1,149 @@ +use std::{ + io::{Cursor, Read}, + pin::Pin, + task::Poll, +}; + +use bytes::Bytes; +use futures::AsyncRead; +use http_body::{Body, Frame}; + +/// Based on the implementation of AsyncBody in +/// . +pub struct AsyncBody(pub Inner); + +pub enum Inner { + /// An empty body. + Empty, + + /// A body stored in memory. + Bytes(std::io::Cursor), + + /// An asynchronous reader. + AsyncReader(Pin>), +} + +impl AsyncBody { + /// Create a new empty body. + /// + /// An empty body represents the *absence* of a body, which is semantically + /// different than the presence of a body of zero length. + pub fn empty() -> Self { + Self(Inner::Empty) + } + /// Create a streaming body that reads from the given reader. + pub fn from_reader(read: R) -> Self + where + R: AsyncRead + Send + Sync + 'static, + { + Self(Inner::AsyncReader(Box::pin(read))) + } + + pub fn from_bytes(bytes: Bytes) -> Self { + Self(Inner::Bytes(Cursor::new(bytes))) + } +} + +impl Default for AsyncBody { + fn default() -> Self { + Self(Inner::Empty) + } +} + +impl From<()> for AsyncBody { + fn from(_: ()) -> Self { + Self(Inner::Empty) + } +} + +impl From for AsyncBody { + fn from(bytes: Bytes) -> Self { + Self::from_bytes(bytes) + } +} + +impl From> for AsyncBody { + fn from(body: Vec) -> Self { + Self::from_bytes(body.into()) + } +} + +impl From for AsyncBody { + fn from(body: String) -> Self { + Self::from_bytes(body.into()) + } +} + +impl From<&'static [u8]> for AsyncBody { + #[inline] + fn from(s: &'static [u8]) -> Self { + Self::from_bytes(Bytes::from_static(s)) + } +} + +impl From<&'static str> for AsyncBody { + #[inline] + fn from(s: &'static str) -> Self { + Self::from_bytes(Bytes::from_static(s.as_bytes())) + } +} + +impl TryFrom for AsyncBody { + type Error = anyhow::Error; + + fn try_from(value: reqwest::Body) -> Result { + value + .as_bytes() + .ok_or_else(|| anyhow::anyhow!("Underlying data is a stream")) + .map(|bytes| Self::from_bytes(Bytes::copy_from_slice(bytes))) + } +} + +impl> From> for AsyncBody { + fn from(body: Option) -> Self { + match body { + Some(body) => body.into(), + None => Self::empty(), + } + } +} + +impl futures::AsyncRead for AsyncBody { + fn poll_read( + self: Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + buf: &mut [u8], + ) -> std::task::Poll> { + // SAFETY: Standard Enum pin projection + let inner = unsafe { &mut self.get_unchecked_mut().0 }; + match inner { + Inner::Empty => Poll::Ready(Ok(0)), + // Blocking call is over an in-memory buffer + Inner::Bytes(cursor) => Poll::Ready(cursor.read(buf)), + Inner::AsyncReader(async_reader) => { + AsyncRead::poll_read(async_reader.as_mut(), cx, buf) + } + } + } +} + +impl Body for AsyncBody { + type Data = Bytes; + type Error = std::io::Error; + + fn poll_frame( + mut self: Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> Poll, Self::Error>>> { + let mut buffer = vec![0; 8192]; + match AsyncRead::poll_read(self.as_mut(), cx, &mut buffer) { + Poll::Ready(Ok(0)) => Poll::Ready(None), + Poll::Ready(Ok(n)) => { + let data = Bytes::copy_from_slice(&buffer[..n]); + Poll::Ready(Some(Ok(Frame::data(data)))) + } + Poll::Ready(Err(e)) => Poll::Ready(Some(Err(e))), + Poll::Pending => Poll::Pending, + } + } +} diff --git a/third_party/gpui_support/gpui_http_client/src/github.rs b/third_party/gpui_support/gpui_http_client/src/github.rs new file mode 100644 index 0000000..32efed8 --- /dev/null +++ b/third_party/gpui_support/gpui_http_client/src/github.rs @@ -0,0 +1,179 @@ +use crate::HttpClient; +use anyhow::{Context as _, Result, anyhow, bail}; +use futures::AsyncReadExt; +use serde::Deserialize; +use std::sync::Arc; +use url::Url; + +pub struct GitHubLspBinaryVersion { + pub name: String, + pub url: String, + pub digest: Option, +} + +#[derive(Deserialize, Debug)] +pub struct GithubRelease { + pub tag_name: String, + #[serde(rename = "prerelease")] + pub pre_release: bool, + pub assets: Vec, + pub tarball_url: String, + pub zipball_url: String, +} + +#[derive(Deserialize, Debug)] +pub struct GithubReleaseAsset { + pub name: String, + pub browser_download_url: String, + pub digest: Option, +} + +pub async fn latest_github_release( + repo_name_with_owner: &str, + require_assets: bool, + pre_release: bool, + http: Arc, +) -> anyhow::Result { + let mut response = http + .get( + format!("https://api.github.com/repos/{repo_name_with_owner}/releases").as_str(), + Default::default(), + true, + ) + .await + .context("error fetching latest release")?; + + let mut body = Vec::new(); + response + .body_mut() + .read_to_end(&mut body) + .await + .context("error reading latest release")?; + + if response.status().is_client_error() { + let text = String::from_utf8_lossy(body.as_slice()); + bail!( + "status error {}, response: {text:?}", + response.status().as_u16() + ); + } + + let releases = match serde_json::from_slice::>(body.as_slice()) { + Ok(releases) => releases, + + Err(err) => { + log::error!("Error deserializing: {err:?}"); + log::error!( + "GitHub API response text: {:?}", + String::from_utf8_lossy(body.as_slice()) + ); + anyhow::bail!("error deserializing latest release: {err:?}"); + } + }; + + let mut release = releases + .into_iter() + .filter(|release| !require_assets || !release.assets.is_empty()) + .find(|release| release.pre_release == pre_release) + .context("finding a prerelease")?; + release.assets.iter_mut().for_each(|asset| { + if let Some(digest) = &mut asset.digest + && let Some(stripped) = digest.strip_prefix("sha256:") + { + *digest = stripped.to_owned(); + } + }); + Ok(release) +} + +pub async fn get_release_by_tag_name( + repo_name_with_owner: &str, + tag: &str, + http: Arc, +) -> anyhow::Result { + let mut response = http + .get( + &format!("https://api.github.com/repos/{repo_name_with_owner}/releases/tags/{tag}"), + Default::default(), + true, + ) + .await + .context("error fetching latest release")?; + + let mut body = Vec::new(); + let status = response.status(); + response + .body_mut() + .read_to_end(&mut body) + .await + .context("error reading latest release")?; + + if status.is_client_error() { + let text = String::from_utf8_lossy(body.as_slice()); + bail!( + "status error {}, response: {text:?}", + response.status().as_u16() + ); + } + + let release = serde_json::from_slice::(body.as_slice()).map_err(|err| { + log::error!("Error deserializing: {err:?}"); + log::error!( + "GitHub API response text: {:?}", + String::from_utf8_lossy(body.as_slice()) + ); + anyhow!("error deserializing GitHub release: {err:?}") + })?; + + Ok(release) +} + +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub enum AssetKind { + TarGz, + Gz, + Zip, +} + +pub fn build_asset_url(repo_name_with_owner: &str, tag: &str, kind: AssetKind) -> Result { + let mut url = Url::parse(&format!( + "https://github.com/{repo_name_with_owner}/archive/refs/tags", + ))?; + // We're pushing this here, because tags may contain `/` and other characters + // that need to be escaped. + let asset_filename = format!( + "{tag}.{extension}", + extension = match kind { + AssetKind::TarGz => "tar.gz", + AssetKind::Gz => "gz", + AssetKind::Zip => "zip", + } + ); + url.path_segments_mut() + .map_err(|()| anyhow!("cannot modify url path segments"))? + .push(&asset_filename); + Ok(url.to_string()) +} + +#[cfg(test)] +mod tests { + use crate::github::{AssetKind, build_asset_url}; + + #[test] + fn test_build_asset_url() { + let tag = "release/2.3.5"; + let repo_name_with_owner = "microsoft/vscode-eslint"; + + let tarball = build_asset_url(repo_name_with_owner, tag, AssetKind::TarGz).unwrap(); + assert_eq!( + tarball, + "https://github.com/microsoft/vscode-eslint/archive/refs/tags/release%2F2.3.5.tar.gz" + ); + + let zip = build_asset_url(repo_name_with_owner, tag, AssetKind::Zip).unwrap(); + assert_eq!( + zip, + "https://github.com/microsoft/vscode-eslint/archive/refs/tags/release%2F2.3.5.zip" + ); + } +} diff --git a/third_party/gpui_support/gpui_http_client/src/github_download.rs b/third_party/gpui_support/gpui_http_client/src/github_download.rs new file mode 100644 index 0000000..02dee08 --- /dev/null +++ b/third_party/gpui_support/gpui_http_client/src/github_download.rs @@ -0,0 +1,189 @@ +use std::{path::Path, pin::Pin, task::Poll}; + +use anyhow::{Context, Result}; +use async_compression::futures::bufread::GzipDecoder; +use futures::{AsyncRead, AsyncSeek, AsyncSeekExt, AsyncWrite, io::BufReader}; +use sha2::{Digest, Sha256}; + +use crate::{HttpClient, github::AssetKind}; + +#[derive(serde::Deserialize, serde::Serialize, Debug)] +pub struct GithubBinaryMetadata { + pub metadata_version: u64, + pub digest: Option, +} + +impl GithubBinaryMetadata { + pub async fn read_from_file(metadata_path: &Path) -> Result { + let metadata_content = async_fs::read_to_string(metadata_path) + .await + .with_context(|| format!("reading metadata file at {metadata_path:?}"))?; + serde_json::from_str(&metadata_content) + .with_context(|| format!("parsing metadata file at {metadata_path:?}")) + } + + pub async fn write_to_file(&self, metadata_path: &Path) -> Result<()> { + let metadata_content = serde_json::to_string(self) + .with_context(|| format!("serializing metadata for {metadata_path:?}"))?; + async_fs::write(metadata_path, metadata_content.as_bytes()) + .await + .with_context(|| format!("writing metadata file at {metadata_path:?}"))?; + Ok(()) + } +} + +pub async fn download_server_binary( + http_client: &dyn HttpClient, + url: &str, + digest: Option<&str>, + destination_path: &Path, + asset_kind: AssetKind, +) -> Result<(), anyhow::Error> { + log::info!("downloading github artifact from {url}"); + let mut response = http_client + .get(url, Default::default(), true) + .await + .with_context(|| format!("downloading release from {url}"))?; + let body = response.body_mut(); + match digest { + Some(expected_sha_256) => { + let temp_asset_file = tempfile::NamedTempFile::new() + .with_context(|| format!("creating a temporary file for {url}"))?; + let (temp_asset_file, _temp_guard) = temp_asset_file.into_parts(); + let mut writer = HashingWriter { + writer: async_fs::File::from(temp_asset_file), + hasher: Sha256::new(), + }; + futures::io::copy(&mut BufReader::new(body), &mut writer) + .await + .with_context(|| { + format!("saving archive contents into the temporary file for {url}",) + })?; + let asset_sha_256 = format!("{:x}", writer.hasher.finalize()); + + anyhow::ensure!( + asset_sha_256 == expected_sha_256, + "{url} asset got SHA-256 mismatch. Expected: {expected_sha_256}, Got: {asset_sha_256}", + ); + writer + .writer + .seek(std::io::SeekFrom::Start(0)) + .await + .with_context(|| format!("seeking temporary file {destination_path:?}",))?; + stream_file_archive(&mut writer.writer, url, destination_path, asset_kind) + .await + .with_context(|| { + format!("extracting downloaded asset for {url} into {destination_path:?}",) + })?; + } + None => stream_response_archive(body, url, destination_path, asset_kind) + .await + .with_context(|| { + format!("extracting response for asset {url} into {destination_path:?}",) + })?, + } + Ok(()) +} + +async fn stream_response_archive( + response: impl AsyncRead + Unpin, + url: &str, + destination_path: &Path, + asset_kind: AssetKind, +) -> Result<()> { + match asset_kind { + AssetKind::TarGz => extract_tar_gz(destination_path, url, response).await?, + AssetKind::Gz => extract_gz(destination_path, url, response).await?, + AssetKind::Zip => { + util::archive::extract_zip(destination_path, response).await?; + } + }; + Ok(()) +} + +async fn stream_file_archive( + file_archive: impl AsyncRead + AsyncSeek + Unpin, + url: &str, + destination_path: &Path, + asset_kind: AssetKind, +) -> Result<()> { + match asset_kind { + AssetKind::TarGz => extract_tar_gz(destination_path, url, file_archive).await?, + AssetKind::Gz => extract_gz(destination_path, url, file_archive).await?, + #[cfg(not(windows))] + AssetKind::Zip => { + util::archive::extract_seekable_zip(destination_path, file_archive).await?; + } + #[cfg(windows)] + AssetKind::Zip => { + util::archive::extract_zip(destination_path, file_archive).await?; + } + }; + Ok(()) +} + +async fn extract_tar_gz( + destination_path: &Path, + url: &str, + from: impl AsyncRead + Unpin, +) -> Result<(), anyhow::Error> { + let decompressed_bytes = GzipDecoder::new(BufReader::new(from)); + let archive = async_tar::Archive::new(decompressed_bytes); + archive + .unpack(&destination_path) + .await + .with_context(|| format!("extracting {url} to {destination_path:?}"))?; + Ok(()) +} + +async fn extract_gz( + destination_path: &Path, + url: &str, + from: impl AsyncRead + Unpin, +) -> Result<(), anyhow::Error> { + let mut decompressed_bytes = GzipDecoder::new(BufReader::new(from)); + let mut file = async_fs::File::create(&destination_path) + .await + .with_context(|| { + format!("creating a file {destination_path:?} for a download from {url}") + })?; + futures::io::copy(&mut decompressed_bytes, &mut file) + .await + .with_context(|| format!("extracting {url} to {destination_path:?}"))?; + Ok(()) +} + +struct HashingWriter { + writer: W, + hasher: Sha256, +} + +impl AsyncWrite for HashingWriter { + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + buf: &[u8], + ) -> Poll> { + match Pin::new(&mut self.writer).poll_write(cx, buf) { + Poll::Ready(Ok(n)) => { + self.hasher.update(&buf[..n]); + Poll::Ready(Ok(n)) + } + other => other, + } + } + + fn poll_flush( + mut self: Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> Poll> { + Pin::new(&mut self.writer).poll_flush(cx) + } + + fn poll_close( + mut self: Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> Poll> { + Pin::new(&mut self.writer).poll_close(cx) + } +} diff --git a/third_party/gpui_support/gpui_http_client/src/http_client.rs b/third_party/gpui_support/gpui_http_client/src/http_client.rs new file mode 100644 index 0000000..056cee4 --- /dev/null +++ b/third_party/gpui_support/gpui_http_client/src/http_client.rs @@ -0,0 +1,481 @@ +mod async_body; +pub mod github; +pub mod github_download; + +pub use anyhow::{Result, anyhow}; +pub use async_body::{AsyncBody, Inner}; +use derive_more::Deref; +use http::HeaderValue; +pub use http::{self, Method, Request, Response, StatusCode, Uri, request::Builder}; + +use futures::{ + FutureExt as _, + future::{self, BoxFuture}, +}; +use parking_lot::Mutex; +#[cfg(feature = "test-support")] +use std::fmt; +use std::{any::type_name, sync::Arc}; +pub use url::Url; + +#[derive(Default, Debug, Clone, PartialEq, Eq, Hash)] +pub enum RedirectPolicy { + #[default] + NoFollow, + FollowLimit(u32), + FollowAll, +} +pub struct FollowRedirects(pub bool); + +pub trait HttpRequestExt { + /// Conditionally modify self with the given closure. + fn when(self, condition: bool, then: impl FnOnce(Self) -> Self) -> Self + where + Self: Sized, + { + if condition { then(self) } else { self } + } + + /// Conditionally unwrap and modify self with the given closure, if the given option is Some. + fn when_some(self, option: Option, then: impl FnOnce(Self, T) -> Self) -> Self + where + Self: Sized, + { + match option { + Some(value) => then(self, value), + None => self, + } + } + + /// Whether or not to follow redirects + fn follow_redirects(self, follow: RedirectPolicy) -> Self; +} + +impl HttpRequestExt for http::request::Builder { + fn follow_redirects(self, follow: RedirectPolicy) -> Self { + self.extension(follow) + } +} + +pub trait HttpClient: 'static + Send + Sync { + fn type_name(&self) -> &'static str; + + fn user_agent(&self) -> Option<&HeaderValue>; + + fn send( + &self, + req: http::Request, + ) -> BoxFuture<'static, anyhow::Result>>; + + fn get( + &self, + uri: &str, + body: AsyncBody, + follow_redirects: bool, + ) -> BoxFuture<'static, anyhow::Result>> { + let request = Builder::new() + .uri(uri) + .follow_redirects(if follow_redirects { + RedirectPolicy::FollowAll + } else { + RedirectPolicy::NoFollow + }) + .body(body); + + match request { + Ok(request) => self.send(request), + Err(e) => Box::pin(async move { Err(e.into()) }), + } + } + + fn post_json( + &self, + uri: &str, + body: AsyncBody, + ) -> BoxFuture<'static, anyhow::Result>> { + let request = Builder::new() + .uri(uri) + .method(Method::POST) + .header("Content-Type", "application/json") + .body(body); + + match request { + Ok(request) => self.send(request), + Err(e) => Box::pin(async move { Err(e.into()) }), + } + } + + fn proxy(&self) -> Option<&Url>; + + #[cfg(feature = "test-support")] + fn as_fake(&self) -> &FakeHttpClient { + panic!("called as_fake on {}", type_name::()) + } + + fn send_multipart_form<'a>( + &'a self, + _url: &str, + _request: reqwest::multipart::Form, + ) -> BoxFuture<'a, anyhow::Result>> { + future::ready(Err(anyhow!("not implemented"))).boxed() + } +} + +/// An [`HttpClient`] that may have a proxy. +#[derive(Deref)] +pub struct HttpClientWithProxy { + #[deref] + client: Arc, + proxy: Option, +} + +impl HttpClientWithProxy { + /// Returns a new [`HttpClientWithProxy`] with the given proxy URL. + pub fn new(client: Arc, proxy_url: Option) -> Self { + let proxy_url = proxy_url + .and_then(|proxy| proxy.parse().ok()) + .or_else(read_proxy_from_env); + + Self::new_url(client, proxy_url) + } + pub fn new_url(client: Arc, proxy_url: Option) -> Self { + Self { + client, + proxy: proxy_url, + } + } +} + +impl HttpClient for HttpClientWithProxy { + fn send( + &self, + req: Request, + ) -> BoxFuture<'static, anyhow::Result>> { + self.client.send(req) + } + + fn user_agent(&self) -> Option<&HeaderValue> { + self.client.user_agent() + } + + fn proxy(&self) -> Option<&Url> { + self.proxy.as_ref() + } + + fn type_name(&self) -> &'static str { + self.client.type_name() + } + + #[cfg(feature = "test-support")] + fn as_fake(&self) -> &FakeHttpClient { + self.client.as_fake() + } + + fn send_multipart_form<'a>( + &'a self, + url: &str, + form: reqwest::multipart::Form, + ) -> BoxFuture<'a, anyhow::Result>> { + self.client.send_multipart_form(url, form) + } +} + +/// An [`HttpClient`] that has a base URL. +pub struct HttpClientWithUrl { + base_url: Mutex, + client: HttpClientWithProxy, +} + +impl std::ops::Deref for HttpClientWithUrl { + type Target = HttpClientWithProxy; + + fn deref(&self) -> &Self::Target { + &self.client + } +} + +impl HttpClientWithUrl { + /// Returns a new [`HttpClientWithUrl`] with the given base URL. + pub fn new( + client: Arc, + base_url: impl Into, + proxy_url: Option, + ) -> Self { + let client = HttpClientWithProxy::new(client, proxy_url); + + Self { + base_url: Mutex::new(base_url.into()), + client, + } + } + + pub fn new_url( + client: Arc, + base_url: impl Into, + proxy_url: Option, + ) -> Self { + let client = HttpClientWithProxy::new_url(client, proxy_url); + + Self { + base_url: Mutex::new(base_url.into()), + client, + } + } + + /// Returns the base URL. + pub fn base_url(&self) -> String { + self.base_url.lock().clone() + } + + /// Sets the base URL. + pub fn set_base_url(&self, base_url: impl Into) { + let base_url = base_url.into(); + *self.base_url.lock() = base_url; + } + + /// Builds a URL using the given path. + pub fn build_url(&self, path: &str) -> String { + format!("{}{}", self.base_url(), path) + } + + /// Builds a Zed API URL using the given path. + pub fn build_zed_api_url(&self, path: &str, query: &[(&str, &str)]) -> Result { + let base_url = self.base_url(); + let base_api_url = match base_url.as_ref() { + "https://zed.dev" => "https://api.zed.dev", + "https://staging.zed.dev" => "https://api-staging.zed.dev", + "http://localhost:3000" => "http://localhost:8080", + other => other, + }; + + Ok(Url::parse_with_params( + &format!("{}{}", base_api_url, path), + query, + )?) + } + + /// Builds a Zed Cloud URL using the given path. + pub fn build_zed_cloud_url(&self, path: &str, query: &[(&str, &str)]) -> Result { + let base_url = self.base_url(); + let base_api_url = match base_url.as_ref() { + "https://zed.dev" => "https://cloud.zed.dev", + "https://staging.zed.dev" => "https://cloud.zed.dev", + "http://localhost:3000" => "http://localhost:8787", + other => other, + }; + + Ok(Url::parse_with_params( + &format!("{}{}", base_api_url, path), + query, + )?) + } + + /// Builds a Zed LLM URL using the given path. + pub fn build_zed_llm_url(&self, path: &str, query: &[(&str, &str)]) -> Result { + let base_url = self.base_url(); + let base_api_url = match base_url.as_ref() { + "https://zed.dev" => "https://cloud.zed.dev", + "https://staging.zed.dev" => "https://llm-staging.zed.dev", + "http://localhost:3000" => "http://localhost:8787", + other => other, + }; + + Ok(Url::parse_with_params( + &format!("{}{}", base_api_url, path), + query, + )?) + } +} + +impl HttpClient for HttpClientWithUrl { + fn send( + &self, + req: Request, + ) -> BoxFuture<'static, anyhow::Result>> { + self.client.send(req) + } + + fn user_agent(&self) -> Option<&HeaderValue> { + self.client.user_agent() + } + + fn proxy(&self) -> Option<&Url> { + self.client.proxy.as_ref() + } + + fn type_name(&self) -> &'static str { + self.client.type_name() + } + + #[cfg(feature = "test-support")] + fn as_fake(&self) -> &FakeHttpClient { + self.client.as_fake() + } + + fn send_multipart_form<'a>( + &'a self, + url: &str, + request: reqwest::multipart::Form, + ) -> BoxFuture<'a, anyhow::Result>> { + self.client.send_multipart_form(url, request) + } +} + +pub fn read_proxy_from_env() -> Option { + const ENV_VARS: &[&str] = &[ + "ALL_PROXY", + "all_proxy", + "HTTPS_PROXY", + "https_proxy", + "HTTP_PROXY", + "http_proxy", + ]; + + ENV_VARS + .iter() + .find_map(|var| std::env::var(var).ok()) + .and_then(|env| env.parse().ok()) +} + +pub fn read_no_proxy_from_env() -> Option { + const ENV_VARS: &[&str] = &["NO_PROXY", "no_proxy"]; + + ENV_VARS.iter().find_map(|var| std::env::var(var).ok()) +} + +pub struct BlockedHttpClient; + +impl BlockedHttpClient { + pub fn new() -> Self { + BlockedHttpClient + } +} + +impl HttpClient for BlockedHttpClient { + fn send( + &self, + _req: Request, + ) -> BoxFuture<'static, anyhow::Result>> { + Box::pin(async { + Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "BlockedHttpClient disallowed request", + ) + .into()) + }) + } + + fn user_agent(&self) -> Option<&HeaderValue> { + None + } + + fn proxy(&self) -> Option<&Url> { + None + } + + fn type_name(&self) -> &'static str { + type_name::() + } + + #[cfg(feature = "test-support")] + fn as_fake(&self) -> &FakeHttpClient { + panic!("called as_fake on {}", type_name::()) + } +} + +#[cfg(feature = "test-support")] +type FakeHttpHandler = Arc< + dyn Fn(Request) -> BoxFuture<'static, anyhow::Result>> + + Send + + Sync + + 'static, +>; + +#[cfg(feature = "test-support")] +pub struct FakeHttpClient { + handler: Mutex>, + user_agent: HeaderValue, +} + +#[cfg(feature = "test-support")] +impl FakeHttpClient { + pub fn create(handler: F) -> Arc + where + Fut: futures::Future>> + Send + 'static, + F: Fn(Request) -> Fut + Send + Sync + 'static, + { + Arc::new(HttpClientWithUrl { + base_url: Mutex::new("http://test.example".into()), + client: HttpClientWithProxy { + client: Arc::new(Self { + handler: Mutex::new(Some(Arc::new(move |req| Box::pin(handler(req))))), + user_agent: HeaderValue::from_static(type_name::()), + }), + proxy: None, + }, + }) + } + + pub fn with_404_response() -> Arc { + Self::create(|_| async move { + Ok(Response::builder() + .status(404) + .body(Default::default()) + .unwrap()) + }) + } + + pub fn with_200_response() -> Arc { + Self::create(|_| async move { + Ok(Response::builder() + .status(200) + .body(Default::default()) + .unwrap()) + }) + } + + pub fn replace_handler(&self, new_handler: F) + where + Fut: futures::Future>> + Send + 'static, + F: Fn(FakeHttpHandler, Request) -> Fut + Send + Sync + 'static, + { + let mut handler = self.handler.lock(); + let old_handler = handler.take().unwrap(); + *handler = Some(Arc::new(move |req| { + Box::pin(new_handler(old_handler.clone(), req)) + })); + } +} + +#[cfg(feature = "test-support")] +impl fmt::Debug for FakeHttpClient { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("FakeHttpClient").finish() + } +} + +#[cfg(feature = "test-support")] +impl HttpClient for FakeHttpClient { + fn send( + &self, + req: Request, + ) -> BoxFuture<'static, anyhow::Result>> { + ((self.handler.lock().as_ref().unwrap())(req)) as _ + } + + fn user_agent(&self) -> Option<&HeaderValue> { + Some(&self.user_agent) + } + + fn proxy(&self) -> Option<&Url> { + None + } + + fn type_name(&self) -> &'static str { + type_name::() + } + + fn as_fake(&self) -> &FakeHttpClient { + self + } +} diff --git a/third_party/gpui_support/gpui_sum_tree/Cargo.toml b/third_party/gpui_support/gpui_sum_tree/Cargo.toml new file mode 100644 index 0000000..2e26709 --- /dev/null +++ b/third_party/gpui_support/gpui_sum_tree/Cargo.toml @@ -0,0 +1,66 @@ +# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO +# +# When uploading crates to the registry Cargo will automatically +# "normalize" Cargo.toml files for maximal compatibility +# with all versions of Cargo and also rewrite `path` dependencies +# to registry (e.g., crates.io) dependencies. +# +# Local copy keeps the registry manifest shape and narrows log features so +# GPUI and Servo can share the same workspace dependency graph. + +[package] +edition = "2024" +name = "gpui_sum_tree" +version = "0.2.2" +build = false +publish = true +autolib = false +autobins = false +autoexamples = false +autotests = false +autobenches = false +description = "A sum tree data structure, a concurrency-friendly B-tree" +readme = false +license = "Apache-2.0" +resolver = "2" + +[lib] +name = "gpui_sum_tree" +path = "src/sum_tree.rs" +doctest = false + +[dependencies.arrayvec] +version = "0.7.1" + +[dependencies.log] +version = "0.4.16" + +[dependencies.rayon] +version = "1.8" + +[dev-dependencies.ctor] +version = "0.4.0" + +[dev-dependencies.rand] +version = "0.9" + +[lints.clippy] +dbg_macro = "deny" +declare_interior_mutable_const = "deny" +disallowed_methods = "deny" +large_enum_variant = "allow" +let_underscore_future = "allow" +nonminimal_bool = "allow" +redundant_clone = "deny" +single_range_in_vec_init = "allow" +todo = "deny" +too_many_arguments = "allow" +type_complexity = "allow" + +[lints.clippy.style] +level = "allow" +priority = -1 + +[lints.rust.unexpected_cfgs] +level = "allow" +priority = 0 diff --git a/third_party/gpui_support/gpui_sum_tree/LICENSE-APACHE b/third_party/gpui_support/gpui_sum_tree/LICENSE-APACHE new file mode 100644 index 0000000..461a0fe --- /dev/null +++ b/third_party/gpui_support/gpui_sum_tree/LICENSE-APACHE @@ -0,0 +1,222 @@ +Copyright 2022 - 2025 Zed Industries, Inc. + + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + + http://www.apache.org/licenses/LICENSE-2.0 + + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + + + +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + + 1. Definitions. + + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + + END OF TERMS AND CONDITIONS diff --git a/third_party/gpui_support/gpui_sum_tree/src/cursor.rs b/third_party/gpui_support/gpui_sum_tree/src/cursor.rs new file mode 100644 index 0000000..7418224 --- /dev/null +++ b/third_party/gpui_support/gpui_sum_tree/src/cursor.rs @@ -0,0 +1,819 @@ +use super::*; +use arrayvec::ArrayVec; +use std::{cmp::Ordering, mem, sync::Arc}; + +#[derive(Clone)] +struct StackEntry<'a, T: Item, D> { + tree: &'a SumTree, + index: u32, + position: D, +} + +impl<'a, T: Item, D> StackEntry<'a, T, D> { + #[inline] + fn index(&self) -> usize { + self.index as usize + } +} + +impl fmt::Debug for StackEntry<'_, T, D> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("StackEntry") + .field("index", &self.index) + .field("position", &self.position) + .finish() + } +} + +#[derive(Clone)] +pub struct Cursor<'a, 'b, T: Item, D> { + tree: &'a SumTree, + stack: ArrayVec, 16>, + position: D, + did_seek: bool, + at_end: bool, + cx: ::Context<'b>, +} + +impl fmt::Debug for Cursor<'_, '_, T, D> +where + T::Summary: fmt::Debug, +{ + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Cursor") + .field("tree", &self.tree) + .field("stack", &self.stack) + .field("position", &self.position) + .field("did_seek", &self.did_seek) + .field("at_end", &self.at_end) + .finish() + } +} + +pub struct Iter<'a, T: Item> { + tree: &'a SumTree, + stack: ArrayVec, 16>, +} + +impl<'a, 'b, T, D> Cursor<'a, 'b, T, D> +where + T: Item, + D: Dimension<'a, T::Summary>, +{ + pub fn new(tree: &'a SumTree, cx: ::Context<'b>) -> Self { + Self { + tree, + stack: ArrayVec::new(), + position: D::zero(cx), + did_seek: false, + at_end: tree.is_empty(), + cx, + } + } + + fn reset(&mut self) { + self.did_seek = false; + self.at_end = self.tree.is_empty(); + self.stack.truncate(0); + self.position = D::zero(self.cx); + } + + pub fn start(&self) -> &D { + &self.position + } + + #[track_caller] + pub fn end(&self) -> D { + if let Some(item_summary) = self.item_summary() { + let mut end = self.start().clone(); + end.add_summary(item_summary, self.cx); + end + } else { + self.start().clone() + } + } + + /// Item is None, when the list is empty, or this cursor is at the end of the list. + #[track_caller] + pub fn item(&self) -> Option<&'a T> { + self.assert_did_seek(); + if let Some(entry) = self.stack.last() { + match *entry.tree.0 { + Node::Leaf { ref items, .. } => { + if entry.index() == items.len() { + None + } else { + Some(&items[entry.index()]) + } + } + _ => unreachable!(), + } + } else { + None + } + } + + #[track_caller] + pub fn item_summary(&self) -> Option<&'a T::Summary> { + self.assert_did_seek(); + if let Some(entry) = self.stack.last() { + match *entry.tree.0 { + Node::Leaf { + ref item_summaries, .. + } => { + if entry.index() == item_summaries.len() { + None + } else { + Some(&item_summaries[entry.index()]) + } + } + _ => unreachable!(), + } + } else { + None + } + } + + #[track_caller] + pub fn next_item(&self) -> Option<&'a T> { + self.assert_did_seek(); + if let Some(entry) = self.stack.last() { + if entry.index() == entry.tree.0.items().len() - 1 { + if let Some(next_leaf) = self.next_leaf() { + Some(next_leaf.0.items().first().unwrap()) + } else { + None + } + } else { + match *entry.tree.0 { + Node::Leaf { ref items, .. } => Some(&items[entry.index() + 1]), + _ => unreachable!(), + } + } + } else if self.at_end { + None + } else { + self.tree.first() + } + } + + #[track_caller] + fn next_leaf(&self) -> Option<&'a SumTree> { + for entry in self.stack.iter().rev().skip(1) { + if entry.index() < entry.tree.0.child_trees().len() - 1 { + match *entry.tree.0 { + Node::Internal { + ref child_trees, .. + } => return Some(child_trees[entry.index() + 1].leftmost_leaf()), + Node::Leaf { .. } => unreachable!(), + }; + } + } + None + } + + #[track_caller] + pub fn prev_item(&self) -> Option<&'a T> { + self.assert_did_seek(); + if let Some(entry) = self.stack.last() { + if entry.index() == 0 { + if let Some(prev_leaf) = self.prev_leaf() { + Some(prev_leaf.0.items().last().unwrap()) + } else { + None + } + } else { + match *entry.tree.0 { + Node::Leaf { ref items, .. } => Some(&items[entry.index() - 1]), + _ => unreachable!(), + } + } + } else if self.at_end { + self.tree.last() + } else { + None + } + } + + #[track_caller] + fn prev_leaf(&self) -> Option<&'a SumTree> { + for entry in self.stack.iter().rev().skip(1) { + if entry.index() != 0 { + match *entry.tree.0 { + Node::Internal { + ref child_trees, .. + } => return Some(child_trees[entry.index() - 1].rightmost_leaf()), + Node::Leaf { .. } => unreachable!(), + }; + } + } + None + } + + #[track_caller] + pub fn prev(&mut self) { + self.search_backward(|_| true) + } + + #[track_caller] + pub fn search_backward(&mut self, mut filter_node: F) + where + F: FnMut(&T::Summary) -> bool, + { + if !self.did_seek { + self.did_seek = true; + self.at_end = true; + } + + if self.at_end { + self.position = D::zero(self.cx); + self.at_end = self.tree.is_empty(); + if !self.tree.is_empty() { + self.stack.push(StackEntry { + tree: self.tree, + index: self.tree.0.child_summaries().len() as u32, + position: D::from_summary(self.tree.summary(), self.cx), + }); + } + } + + let mut descending = false; + while !self.stack.is_empty() { + if let Some(StackEntry { position, .. }) = self.stack.iter().rev().nth(1) { + self.position = position.clone(); + } else { + self.position = D::zero(self.cx); + } + + let entry = self.stack.last_mut().unwrap(); + if !descending { + if entry.index() == 0 { + self.stack.pop(); + continue; + } else { + entry.index -= 1; + } + } + + for summary in &entry.tree.0.child_summaries()[..entry.index()] { + self.position.add_summary(summary, self.cx); + } + entry.position = self.position.clone(); + + descending = filter_node(&entry.tree.0.child_summaries()[entry.index()]); + match entry.tree.0.as_ref() { + Node::Internal { child_trees, .. } => { + if descending { + let tree = &child_trees[entry.index()]; + self.stack.push(StackEntry { + position: D::zero(self.cx), + tree, + index: tree.0.child_summaries().len() as u32 - 1, + }) + } + } + Node::Leaf { .. } => { + if descending { + break; + } + } + } + } + } + + #[track_caller] + pub fn next(&mut self) { + self.search_forward(|_| true) + } + + #[track_caller] + pub fn search_forward(&mut self, mut filter_node: F) + where + F: FnMut(&T::Summary) -> bool, + { + let mut descend = false; + + if self.stack.is_empty() { + if !self.at_end { + self.stack.push(StackEntry { + tree: self.tree, + index: 0, + position: D::zero(self.cx), + }); + descend = true; + } + self.did_seek = true; + } + + while !self.stack.is_empty() { + let new_subtree = { + let entry = self.stack.last_mut().unwrap(); + match entry.tree.0.as_ref() { + Node::Internal { + child_trees, + child_summaries, + .. + } => { + if !descend { + entry.index += 1; + entry.position = self.position.clone(); + } + + while entry.index() < child_summaries.len() { + let next_summary = &child_summaries[entry.index()]; + if filter_node(next_summary) { + break; + } else { + entry.index += 1; + entry.position.add_summary(next_summary, self.cx); + self.position.add_summary(next_summary, self.cx); + } + } + + child_trees.get(entry.index()) + } + Node::Leaf { item_summaries, .. } => { + if !descend { + let item_summary = &item_summaries[entry.index()]; + entry.index += 1; + entry.position.add_summary(item_summary, self.cx); + self.position.add_summary(item_summary, self.cx); + } + + loop { + if let Some(next_item_summary) = item_summaries.get(entry.index()) { + if filter_node(next_item_summary) { + return; + } else { + entry.index += 1; + entry.position.add_summary(next_item_summary, self.cx); + self.position.add_summary(next_item_summary, self.cx); + } + } else { + break None; + } + } + } + } + }; + + if let Some(subtree) = new_subtree { + descend = true; + self.stack.push(StackEntry { + tree: subtree, + index: 0, + position: self.position.clone(), + }); + } else { + descend = false; + self.stack.pop(); + } + } + + self.at_end = self.stack.is_empty(); + debug_assert!(self.stack.is_empty() || self.stack.last().unwrap().tree.0.is_leaf()); + } + + #[track_caller] + fn assert_did_seek(&self) { + assert!( + self.did_seek, + "Must call `seek`, `next` or `prev` before calling this method" + ); + } +} + +impl<'a, 'b, T, D> Cursor<'a, 'b, T, D> +where + T: Item, + D: Dimension<'a, T::Summary>, +{ + /// Returns whether we found the item you were seeking for. + #[track_caller] + pub fn seek(&mut self, pos: &Target, bias: Bias) -> bool + where + Target: SeekTarget<'a, T::Summary, D>, + { + self.reset(); + self.seek_internal(pos, bias, &mut ()) + } + + /// Returns whether we found the item you were seeking for. + #[track_caller] + pub fn seek_forward(&mut self, pos: &Target, bias: Bias) -> bool + where + Target: SeekTarget<'a, T::Summary, D>, + { + self.seek_internal(pos, bias, &mut ()) + } + + /// Advances the cursor and returns traversed items as a tree. + #[track_caller] + pub fn slice(&mut self, end: &Target, bias: Bias) -> SumTree + where + Target: SeekTarget<'a, T::Summary, D>, + { + let mut slice = SliceSeekAggregate { + tree: SumTree::new(self.cx), + leaf_items: ArrayVec::new(), + leaf_item_summaries: ArrayVec::new(), + leaf_summary: ::zero(self.cx), + }; + self.seek_internal(end, bias, &mut slice); + slice.tree + } + + #[track_caller] + pub fn suffix(&mut self) -> SumTree { + self.slice(&End::new(), Bias::Right) + } + + #[track_caller] + pub fn summary(&mut self, end: &Target, bias: Bias) -> Output + where + Target: SeekTarget<'a, T::Summary, D>, + Output: Dimension<'a, T::Summary>, + { + let mut summary = SummarySeekAggregate(Output::zero(self.cx)); + self.seek_internal(end, bias, &mut summary); + summary.0 + } + + /// Returns whether we found the item you were seeking for. + #[track_caller] + fn seek_internal( + &mut self, + target: &dyn SeekTarget<'a, T::Summary, D>, + bias: Bias, + aggregate: &mut dyn SeekAggregate<'a, T>, + ) -> bool { + assert!( + target.cmp(&self.position, self.cx) >= Ordering::Equal, + "cannot seek backward", + ); + + if !self.did_seek { + self.did_seek = true; + self.stack.push(StackEntry { + tree: self.tree, + index: 0, + position: D::zero(self.cx), + }); + } + + let mut ascending = false; + 'outer: while let Some(entry) = self.stack.last_mut() { + match *entry.tree.0 { + Node::Internal { + ref child_summaries, + ref child_trees, + .. + } => { + if ascending { + entry.index += 1; + entry.position = self.position.clone(); + } + + for (child_tree, child_summary) in child_trees[entry.index()..] + .iter() + .zip(&child_summaries[entry.index()..]) + { + let mut child_end = self.position.clone(); + child_end.add_summary(child_summary, self.cx); + + let comparison = target.cmp(&child_end, self.cx); + if comparison == Ordering::Greater + || (comparison == Ordering::Equal && bias == Bias::Right) + { + self.position = child_end; + aggregate.push_tree(child_tree, child_summary, self.cx); + entry.index += 1; + entry.position = self.position.clone(); + } else { + self.stack.push(StackEntry { + tree: child_tree, + index: 0, + position: self.position.clone(), + }); + ascending = false; + continue 'outer; + } + } + } + Node::Leaf { + ref items, + ref item_summaries, + .. + } => { + aggregate.begin_leaf(); + + for (item, item_summary) in items[entry.index()..] + .iter() + .zip(&item_summaries[entry.index()..]) + { + let mut child_end = self.position.clone(); + child_end.add_summary(item_summary, self.cx); + + let comparison = target.cmp(&child_end, self.cx); + if comparison == Ordering::Greater + || (comparison == Ordering::Equal && bias == Bias::Right) + { + self.position = child_end; + aggregate.push_item(item, item_summary, self.cx); + entry.index += 1; + } else { + aggregate.end_leaf(self.cx); + break 'outer; + } + } + + aggregate.end_leaf(self.cx); + } + } + + self.stack.pop(); + ascending = true; + } + + self.at_end = self.stack.is_empty(); + debug_assert!(self.stack.is_empty() || self.stack.last().unwrap().tree.0.is_leaf()); + + let mut end = self.position.clone(); + if bias == Bias::Left + && let Some(summary) = self.item_summary() + { + end.add_summary(summary, self.cx); + } + + target.cmp(&end, self.cx) == Ordering::Equal + } +} + +impl<'a, T: Item> Iter<'a, T> { + pub(crate) fn new(tree: &'a SumTree) -> Self { + Self { + tree, + stack: Default::default(), + } + } +} + +impl<'a, T: Item> Iterator for Iter<'a, T> { + type Item = &'a T; + + fn next(&mut self) -> Option { + let mut descend = false; + + if self.stack.is_empty() { + self.stack.push(StackEntry { + tree: self.tree, + index: 0, + position: (), + }); + descend = true; + } + + while !self.stack.is_empty() { + let new_subtree = { + let entry = self.stack.last_mut().unwrap(); + match entry.tree.0.as_ref() { + Node::Internal { child_trees, .. } => { + if !descend { + entry.index += 1; + } + child_trees.get(entry.index()) + } + Node::Leaf { items, .. } => { + if !descend { + entry.index += 1; + } + + if let Some(next_item) = items.get(entry.index()) { + return Some(next_item); + } else { + None + } + } + } + }; + + if let Some(subtree) = new_subtree { + descend = true; + self.stack.push(StackEntry { + tree: subtree, + index: 0, + position: (), + }); + } else { + descend = false; + self.stack.pop(); + } + } + + None + } +} + +impl<'a, 'b, T: Item, D> Iterator for Cursor<'a, 'b, T, D> +where + D: Dimension<'a, T::Summary>, +{ + type Item = &'a T; + + fn next(&mut self) -> Option { + if !self.did_seek { + self.next(); + } + + if let Some(item) = self.item() { + self.next(); + Some(item) + } else { + None + } + } +} + +pub struct FilterCursor<'a, 'b, F, T: Item, D> { + cursor: Cursor<'a, 'b, T, D>, + filter_node: F, +} + +impl<'a, 'b, F, T: Item, D> FilterCursor<'a, 'b, F, T, D> +where + F: FnMut(&T::Summary) -> bool, + T: Item, + D: Dimension<'a, T::Summary>, +{ + pub fn new( + tree: &'a SumTree, + cx: ::Context<'b>, + filter_node: F, + ) -> Self { + let cursor = tree.cursor::(cx); + Self { + cursor, + filter_node, + } + } + + pub fn start(&self) -> &D { + self.cursor.start() + } + + pub fn end(&self) -> D { + self.cursor.end() + } + + pub fn item(&self) -> Option<&'a T> { + self.cursor.item() + } + + pub fn item_summary(&self) -> Option<&'a T::Summary> { + self.cursor.item_summary() + } + + pub fn next(&mut self) { + self.cursor.search_forward(&mut self.filter_node); + } + + pub fn prev(&mut self) { + self.cursor.search_backward(&mut self.filter_node); + } +} + +impl<'a, 'b, F, T: Item, U> Iterator for FilterCursor<'a, 'b, F, T, U> +where + F: FnMut(&T::Summary) -> bool, + U: Dimension<'a, T::Summary>, +{ + type Item = &'a T; + + fn next(&mut self) -> Option { + if !self.cursor.did_seek { + self.next(); + } + + if let Some(item) = self.item() { + self.cursor.search_forward(&mut self.filter_node); + Some(item) + } else { + None + } + } +} + +trait SeekAggregate<'a, T: Item> { + fn begin_leaf(&mut self); + fn end_leaf(&mut self, cx: ::Context<'_>); + fn push_item( + &mut self, + item: &'a T, + summary: &'a T::Summary, + cx: ::Context<'_>, + ); + fn push_tree( + &mut self, + tree: &'a SumTree, + summary: &'a T::Summary, + cx: ::Context<'_>, + ); +} + +struct SliceSeekAggregate { + tree: SumTree, + leaf_items: ArrayVec, + leaf_item_summaries: ArrayVec, + leaf_summary: T::Summary, +} + +struct SummarySeekAggregate(D); + +impl SeekAggregate<'_, T> for () { + fn begin_leaf(&mut self) {} + fn end_leaf(&mut self, _: ::Context<'_>) {} + fn push_item(&mut self, _: &T, _: &T::Summary, _: ::Context<'_>) {} + fn push_tree( + &mut self, + _: &SumTree, + _: &T::Summary, + _: ::Context<'_>, + ) { + } +} + +impl SeekAggregate<'_, T> for SliceSeekAggregate { + fn begin_leaf(&mut self) {} + fn end_leaf(&mut self, cx: ::Context<'_>) { + self.tree.append( + SumTree(Arc::new(Node::Leaf { + summary: mem::replace(&mut self.leaf_summary, ::zero(cx)), + items: mem::take(&mut self.leaf_items), + item_summaries: mem::take(&mut self.leaf_item_summaries), + })), + cx, + ); + } + fn push_item( + &mut self, + item: &T, + summary: &T::Summary, + cx: ::Context<'_>, + ) { + self.leaf_items.push(item.clone()); + self.leaf_item_summaries.push(summary.clone()); + Summary::add_summary(&mut self.leaf_summary, summary, cx); + } + fn push_tree( + &mut self, + tree: &SumTree, + _: &T::Summary, + cx: ::Context<'_>, + ) { + self.tree.append(tree.clone(), cx); + } +} + +impl<'a, T: Item, D> SeekAggregate<'a, T> for SummarySeekAggregate +where + D: Dimension<'a, T::Summary>, +{ + fn begin_leaf(&mut self) {} + fn end_leaf(&mut self, _: ::Context<'_>) {} + fn push_item( + &mut self, + _: &T, + summary: &'a T::Summary, + cx: ::Context<'_>, + ) { + self.0.add_summary(summary, cx); + } + fn push_tree( + &mut self, + _: &SumTree, + summary: &'a T::Summary, + cx: ::Context<'_>, + ) { + self.0.add_summary(summary, cx); + } +} + +struct End(PhantomData); + +impl End { + fn new() -> Self { + Self(PhantomData) + } +} + +impl<'a, S: Summary, D: Dimension<'a, S>> SeekTarget<'a, S, D> for End { + fn cmp(&self, _: &D, _: S::Context<'_>) -> Ordering { + Ordering::Greater + } +} + +impl fmt::Debug for End { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_tuple("End").finish() + } +} diff --git a/third_party/gpui_support/gpui_sum_tree/src/sum_tree.rs b/third_party/gpui_support/gpui_sum_tree/src/sum_tree.rs new file mode 100644 index 0000000..ab0e9d0 --- /dev/null +++ b/third_party/gpui_support/gpui_sum_tree/src/sum_tree.rs @@ -0,0 +1,1558 @@ +mod cursor; +mod tree_map; + +use arrayvec::ArrayVec; +pub use cursor::{Cursor, FilterCursor, Iter}; +use rayon::prelude::*; +use std::marker::PhantomData; +use std::mem; +use std::{cmp::Ordering, fmt, iter::FromIterator, sync::Arc}; +pub use tree_map::{MapSeekTarget, TreeMap, TreeSet}; + +#[cfg(test)] +pub const TREE_BASE: usize = 2; +#[cfg(not(test))] +pub const TREE_BASE: usize = 6; + +/// An item that can be stored in a [`SumTree`] +/// +/// Must be summarized by a type that implements [`Summary`] +pub trait Item: Clone { + type Summary: Summary; + + fn summary(&self, cx: ::Context<'_>) -> Self::Summary; +} + +/// An [`Item`] whose summary has a specific key that can be used to identify it +pub trait KeyedItem: Item { + type Key: for<'a> Dimension<'a, Self::Summary> + Ord; + + fn key(&self) -> Self::Key; +} + +/// A type that describes the Sum of all [`Item`]s in a subtree of the [`SumTree`] +/// +/// Each Summary type can have multiple [`Dimension`]s that it measures, +/// which can be used to navigate the tree +pub trait Summary: Clone { + type Context<'a>: Copy; + fn zero<'a>(cx: Self::Context<'a>) -> Self; + fn add_summary<'a>(&mut self, summary: &Self, cx: Self::Context<'a>); +} + +pub trait ContextLessSummary: Clone { + fn zero() -> Self; + fn add_summary(&mut self, summary: &Self); +} + +impl Summary for T { + type Context<'a> = (); + + fn zero<'a>((): ()) -> Self { + T::zero() + } + + fn add_summary<'a>(&mut self, summary: &Self, (): ()) { + T::add_summary(self, summary) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct NoSummary; + +/// Catch-all implementation for when you need something that implements [`Summary`] without a specific type. +/// We implement it on a `NoSummary` instead of re-using `()`, as that avoids blanket impl collisions with `impl Dimension for T` +/// (as we also need unit type to be a fill-in dimension) +impl ContextLessSummary for NoSummary { + fn zero() -> Self { + NoSummary + } + + fn add_summary(&mut self, _: &Self) {} +} + +/// Each [`Summary`] type can have more than one [`Dimension`] type that it measures. +/// +/// You can use dimensions to seek to a specific location in the [`SumTree`] +/// +/// # Example: +/// Zed's rope has a `TextSummary` type that summarizes lines, characters, and bytes. +/// Each of these are different dimensions we may want to seek to +pub trait Dimension<'a, S: Summary>: Clone { + fn zero(cx: S::Context<'_>) -> Self; + + fn add_summary(&mut self, summary: &'a S, cx: S::Context<'_>); + #[must_use] + fn with_added_summary(mut self, summary: &'a S, cx: S::Context<'_>) -> Self { + self.add_summary(summary, cx); + self + } + + fn from_summary(summary: &'a S, cx: S::Context<'_>) -> Self { + let mut dimension = Self::zero(cx); + dimension.add_summary(summary, cx); + dimension + } +} + +impl<'a, T: Summary> Dimension<'a, T> for T { + fn zero(cx: T::Context<'_>) -> Self { + Summary::zero(cx) + } + + fn add_summary(&mut self, summary: &'a T, cx: T::Context<'_>) { + Summary::add_summary(self, summary, cx); + } +} + +pub trait SeekTarget<'a, S: Summary, D: Dimension<'a, S>> { + fn cmp(&self, cursor_location: &D, cx: S::Context<'_>) -> Ordering; +} + +impl<'a, S: Summary, D: Dimension<'a, S> + Ord> SeekTarget<'a, S, D> for D { + fn cmp(&self, cursor_location: &Self, _: S::Context<'_>) -> Ordering { + Ord::cmp(self, cursor_location) + } +} + +impl<'a, T: Summary> Dimension<'a, T> for () { + fn zero(_: T::Context<'_>) -> Self {} + + fn add_summary(&mut self, _: &'a T, _: T::Context<'_>) {} +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)] +pub struct Dimensions(pub D1, pub D2, pub D3); + +impl<'a, T: Summary, D1: Dimension<'a, T>, D2: Dimension<'a, T>, D3: Dimension<'a, T>> + Dimension<'a, T> for Dimensions +{ + fn zero(cx: T::Context<'_>) -> Self { + Dimensions(D1::zero(cx), D2::zero(cx), D3::zero(cx)) + } + + fn add_summary(&mut self, summary: &'a T, cx: T::Context<'_>) { + self.0.add_summary(summary, cx); + self.1.add_summary(summary, cx); + self.2.add_summary(summary, cx); + } +} + +impl<'a, S, D1, D2, D3> SeekTarget<'a, S, Dimensions> for D1 +where + S: Summary, + D1: SeekTarget<'a, S, D1> + Dimension<'a, S>, + D2: Dimension<'a, S>, + D3: Dimension<'a, S>, +{ + fn cmp(&self, cursor_location: &Dimensions, cx: S::Context<'_>) -> Ordering { + self.cmp(&cursor_location.0, cx) + } +} + +/// Bias is used to settle ambiguities when determining positions in an ordered sequence. +/// +/// The primary use case is for text, where Bias influences +/// which character an offset or anchor is associated with. +/// +/// # Examples +/// Given the buffer `AˇBCD`: +/// - The offset of the cursor is 1 +/// - [Bias::Left] would attach the cursor to the character `A` +/// - [Bias::Right] would attach the cursor to the character `B` +/// +/// Given the buffer `A«BCˇ»D`: +/// - The offset of the cursor is 3, and the selection is from 1 to 3 +/// - The left anchor of the selection has [Bias::Right], attaching it to the character `B` +/// - The right anchor of the selection has [Bias::Left], attaching it to the character `C` +/// +/// Given the buffer `{ˇ<...>`, where `<...>` is a folded region: +/// - The display offset of the cursor is 1, but the offset in the buffer is determined by the bias +/// - [Bias::Left] would attach the cursor to the character `{`, with a buffer offset of 1 +/// - [Bias::Right] would attach the cursor to the first character of the folded region, +/// and the buffer offset would be the offset of the first character of the folded region +#[derive(Copy, Clone, Eq, PartialEq, PartialOrd, Ord, Debug, Hash, Default)] +pub enum Bias { + /// Attach to the character on the left + #[default] + Left, + /// Attach to the character on the right + Right, +} + +impl Bias { + pub fn invert(self) -> Self { + match self { + Self::Left => Self::Right, + Self::Right => Self::Left, + } + } +} + +/// A B+ tree in which each leaf node contains `Item`s of type `T` and a `Summary`s for each `Item`. +/// Each internal node contains a `Summary` of the items in its subtree. +/// +/// The maximum number of items per node is `TREE_BASE * 2`. +/// +/// Any [`Dimension`] supported by the [`Summary`] type can be used to seek to a specific location in the tree. +#[derive(Clone)] +pub struct SumTree(Arc>); + +impl fmt::Debug for SumTree +where + T: fmt::Debug + Item, + T::Summary: fmt::Debug, +{ + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.debug_tuple("SumTree").field(&self.0).finish() + } +} + +impl SumTree { + pub fn new(cx: ::Context<'_>) -> Self { + SumTree(Arc::new(Node::Leaf { + summary: ::zero(cx), + items: ArrayVec::new(), + item_summaries: ArrayVec::new(), + })) + } + + /// Useful in cases where the item type has a non-trivial context type, but the zero value of the summary type doesn't depend on that context. + pub fn from_summary(summary: T::Summary) -> Self { + SumTree(Arc::new(Node::Leaf { + summary, + items: ArrayVec::new(), + item_summaries: ArrayVec::new(), + })) + } + + pub fn from_item(item: T, cx: ::Context<'_>) -> Self { + let mut tree = Self::new(cx); + tree.push(item, cx); + tree + } + + pub fn from_iter>( + iter: I, + cx: ::Context<'_>, + ) -> Self { + let mut nodes = Vec::new(); + + let mut iter = iter.into_iter().fuse().peekable(); + while iter.peek().is_some() { + let items: ArrayVec = iter.by_ref().take(2 * TREE_BASE).collect(); + let item_summaries: ArrayVec = + items.iter().map(|item| item.summary(cx)).collect(); + + let mut summary = item_summaries[0].clone(); + for item_summary in &item_summaries[1..] { + ::add_summary(&mut summary, item_summary, cx); + } + + nodes.push(Node::Leaf { + summary, + items, + item_summaries, + }); + } + + let mut parent_nodes = Vec::new(); + let mut height = 0; + while nodes.len() > 1 { + height += 1; + let mut current_parent_node = None; + for child_node in nodes.drain(..) { + let parent_node = current_parent_node.get_or_insert_with(|| Node::Internal { + summary: ::zero(cx), + height, + child_summaries: ArrayVec::new(), + child_trees: ArrayVec::new(), + }); + let Node::Internal { + summary, + child_summaries, + child_trees, + .. + } = parent_node + else { + unreachable!() + }; + let child_summary = child_node.summary(); + ::add_summary(summary, child_summary, cx); + child_summaries.push(child_summary.clone()); + child_trees.push(Self(Arc::new(child_node))); + + if child_trees.len() == 2 * TREE_BASE { + parent_nodes.extend(current_parent_node.take()); + } + } + parent_nodes.extend(current_parent_node.take()); + mem::swap(&mut nodes, &mut parent_nodes); + } + + if nodes.is_empty() { + Self::new(cx) + } else { + debug_assert_eq!(nodes.len(), 1); + Self(Arc::new(nodes.pop().unwrap())) + } + } + + pub fn from_par_iter(iter: I, cx: ::Context<'_>) -> Self + where + I: IntoParallelIterator, + Iter: IndexedParallelIterator, + T: Send + Sync, + T::Summary: Send + Sync, + for<'a> ::Context<'a>: Sync, + { + let mut nodes = iter + .into_par_iter() + .chunks(2 * TREE_BASE) + .map(|items| { + let items: ArrayVec = items.into_iter().collect(); + let item_summaries: ArrayVec = + items.iter().map(|item| item.summary(cx)).collect(); + let mut summary = item_summaries[0].clone(); + for item_summary in &item_summaries[1..] { + ::add_summary(&mut summary, item_summary, cx); + } + SumTree(Arc::new(Node::Leaf { + summary, + items, + item_summaries, + })) + }) + .collect::>(); + + let mut height = 0; + while nodes.len() > 1 { + height += 1; + nodes = nodes + .into_par_iter() + .chunks(2 * TREE_BASE) + .map(|child_nodes| { + let child_trees: ArrayVec, { 2 * TREE_BASE }> = + child_nodes.into_iter().collect(); + let child_summaries: ArrayVec = child_trees + .iter() + .map(|child_tree| child_tree.summary().clone()) + .collect(); + let mut summary = child_summaries[0].clone(); + for child_summary in &child_summaries[1..] { + ::add_summary(&mut summary, child_summary, cx); + } + SumTree(Arc::new(Node::Internal { + height, + summary, + child_summaries, + child_trees, + })) + }) + .collect::>(); + } + + if nodes.is_empty() { + Self::new(cx) + } else { + debug_assert_eq!(nodes.len(), 1); + nodes.pop().unwrap() + } + } + + #[allow(unused)] + pub fn items<'a>(&'a self, cx: ::Context<'a>) -> Vec { + let mut items = Vec::new(); + let mut cursor = self.cursor::<()>(cx); + cursor.next(); + while let Some(item) = cursor.item() { + items.push(item.clone()); + cursor.next(); + } + items + } + + pub fn iter(&self) -> Iter<'_, T> { + Iter::new(self) + } + + /// A more efficient version of `Cursor::new()` + `Cursor::seek()` + `Cursor::item()`. + /// + /// Only returns the item that exactly has the target match. + pub fn find_exact<'a, 'slf, D, Target>( + &'slf self, + cx: ::Context<'a>, + target: &Target, + bias: Bias, + ) -> (D, D, Option<&'slf T>) + where + D: Dimension<'slf, T::Summary>, + Target: SeekTarget<'slf, T::Summary, D>, + { + let tree_end = D::zero(cx).with_added_summary(self.summary(), cx); + let comparison = target.cmp(&tree_end, cx); + if comparison == Ordering::Greater || (comparison == Ordering::Equal && bias == Bias::Right) + { + return (tree_end.clone(), tree_end, None); + } + + let mut pos = D::zero(cx); + return match Self::find_recurse::<_, _, true>(cx, target, bias, &mut pos, self) { + Some((item, end)) => (pos, end, Some(item)), + None => (pos.clone(), pos, None), + }; + } + + /// A more efficient version of `Cursor::new()` + `Cursor::seek()` + `Cursor::item()` + pub fn find<'a, 'slf, D, Target>( + &'slf self, + cx: ::Context<'a>, + target: &Target, + bias: Bias, + ) -> (D, D, Option<&'slf T>) + where + D: Dimension<'slf, T::Summary>, + Target: SeekTarget<'slf, T::Summary, D>, + { + let tree_end = D::zero(cx).with_added_summary(self.summary(), cx); + let comparison = target.cmp(&tree_end, cx); + if comparison == Ordering::Greater || (comparison == Ordering::Equal && bias == Bias::Right) + { + return (tree_end.clone(), tree_end, None); + } + + let mut pos = D::zero(cx); + return match Self::find_recurse::<_, _, false>(cx, target, bias, &mut pos, self) { + Some((item, end)) => (pos, end, Some(item)), + None => (pos.clone(), pos, None), + }; + } + + fn find_recurse<'tree, 'a, D, Target, const EXACT: bool>( + cx: ::Context<'a>, + target: &Target, + bias: Bias, + position: &mut D, + this: &'tree SumTree, + ) -> Option<(&'tree T, D)> + where + D: Dimension<'tree, T::Summary>, + Target: SeekTarget<'tree, T::Summary, D>, + { + match &*this.0 { + Node::Internal { + child_summaries, + child_trees, + .. + } => { + for (child_tree, child_summary) in child_trees.iter().zip(child_summaries) { + let child_end = position.clone().with_added_summary(child_summary, cx); + + let comparison = target.cmp(&child_end, cx); + let target_in_child = comparison == Ordering::Less + || (comparison == Ordering::Equal && bias == Bias::Left); + if target_in_child { + return Self::find_recurse::( + cx, target, bias, position, child_tree, + ); + } + *position = child_end; + } + } + Node::Leaf { + items, + item_summaries, + .. + } => { + for (item, item_summary) in items.iter().zip(item_summaries) { + let mut child_end = position.clone(); + child_end.add_summary(item_summary, cx); + + let comparison = target.cmp(&child_end, cx); + let entry_found = if EXACT { + comparison == Ordering::Equal + } else { + comparison == Ordering::Less + || (comparison == Ordering::Equal && bias == Bias::Left) + }; + if entry_found { + return Some((item, child_end)); + } + + *position = child_end; + } + } + } + None + } + + pub fn cursor<'a, 'b, D>( + &'a self, + cx: ::Context<'b>, + ) -> Cursor<'a, 'b, T, D> + where + D: Dimension<'a, T::Summary>, + { + Cursor::new(self, cx) + } + + /// Note: If the summary type requires a non `()` context, then the filter cursor + /// that is returned cannot be used with Rust's iterators. + pub fn filter<'a, 'b, F, U>( + &'a self, + cx: ::Context<'b>, + filter_node: F, + ) -> FilterCursor<'a, 'b, F, T, U> + where + F: FnMut(&T::Summary) -> bool, + U: Dimension<'a, T::Summary>, + { + FilterCursor::new(self, cx, filter_node) + } + + #[allow(dead_code)] + pub fn first(&self) -> Option<&T> { + self.leftmost_leaf().0.items().first() + } + + pub fn last(&self) -> Option<&T> { + self.rightmost_leaf().0.items().last() + } + + pub fn update_last( + &mut self, + f: impl FnOnce(&mut T), + cx: ::Context<'_>, + ) { + self.update_last_recursive(f, cx); + } + + fn update_last_recursive( + &mut self, + f: impl FnOnce(&mut T), + cx: ::Context<'_>, + ) -> Option { + match Arc::make_mut(&mut self.0) { + Node::Internal { + summary, + child_summaries, + child_trees, + .. + } => { + let last_summary = child_summaries.last_mut().unwrap(); + let last_child = child_trees.last_mut().unwrap(); + *last_summary = last_child.update_last_recursive(f, cx).unwrap(); + *summary = sum(child_summaries.iter(), cx); + Some(summary.clone()) + } + Node::Leaf { + summary, + items, + item_summaries, + } => { + if let Some((item, item_summary)) = items.last_mut().zip(item_summaries.last_mut()) + { + (f)(item); + *item_summary = item.summary(cx); + *summary = sum(item_summaries.iter(), cx); + Some(summary.clone()) + } else { + None + } + } + } + } + + pub fn extent<'a, D: Dimension<'a, T::Summary>>( + &'a self, + cx: ::Context<'_>, + ) -> D { + let mut extent = D::zero(cx); + match self.0.as_ref() { + Node::Internal { summary, .. } | Node::Leaf { summary, .. } => { + extent.add_summary(summary, cx); + } + } + extent + } + + pub fn summary(&self) -> &T::Summary { + match self.0.as_ref() { + Node::Internal { summary, .. } => summary, + Node::Leaf { summary, .. } => summary, + } + } + + pub fn is_empty(&self) -> bool { + match self.0.as_ref() { + Node::Internal { .. } => false, + Node::Leaf { items, .. } => items.is_empty(), + } + } + + pub fn extend(&mut self, iter: I, cx: ::Context<'_>) + where + I: IntoIterator, + { + self.append(Self::from_iter(iter, cx), cx); + } + + pub fn par_extend(&mut self, iter: I, cx: ::Context<'_>) + where + I: IntoParallelIterator, + Iter: IndexedParallelIterator, + T: Send + Sync, + T::Summary: Send + Sync, + for<'a> ::Context<'a>: Sync, + { + self.append(Self::from_par_iter(iter, cx), cx); + } + + pub fn push(&mut self, item: T, cx: ::Context<'_>) { + let summary = item.summary(cx); + self.append( + SumTree(Arc::new(Node::Leaf { + summary: summary.clone(), + items: ArrayVec::from_iter(Some(item)), + item_summaries: ArrayVec::from_iter(Some(summary)), + })), + cx, + ); + } + + pub fn append(&mut self, other: Self, cx: ::Context<'_>) { + if self.is_empty() { + *self = other; + } else if !other.0.is_leaf() || !other.0.items().is_empty() { + if self.0.height() < other.0.height() { + for tree in other.0.child_trees() { + self.append(tree.clone(), cx); + } + } else if let Some(split_tree) = self.push_tree_recursive(other, cx) { + *self = Self::from_child_trees(self.clone(), split_tree, cx); + } + } + } + + fn push_tree_recursive( + &mut self, + other: SumTree, + cx: ::Context<'_>, + ) -> Option> { + match Arc::make_mut(&mut self.0) { + Node::Internal { + height, + summary, + child_summaries, + child_trees, + .. + } => { + let other_node = other.0.clone(); + ::add_summary(summary, other_node.summary(), cx); + + let height_delta = *height - other_node.height(); + let mut summaries_to_append = ArrayVec::::new(); + let mut trees_to_append = ArrayVec::, { 2 * TREE_BASE }>::new(); + if height_delta == 0 { + summaries_to_append.extend(other_node.child_summaries().iter().cloned()); + trees_to_append.extend(other_node.child_trees().iter().cloned()); + } else if height_delta == 1 && !other_node.is_underflowing() { + summaries_to_append.push(other_node.summary().clone()); + trees_to_append.push(other) + } else { + let tree_to_append = child_trees + .last_mut() + .unwrap() + .push_tree_recursive(other, cx); + *child_summaries.last_mut().unwrap() = + child_trees.last().unwrap().0.summary().clone(); + + if let Some(split_tree) = tree_to_append { + summaries_to_append.push(split_tree.0.summary().clone()); + trees_to_append.push(split_tree); + } + } + + let child_count = child_trees.len() + trees_to_append.len(); + if child_count > 2 * TREE_BASE { + let left_summaries: ArrayVec<_, { 2 * TREE_BASE }>; + let right_summaries: ArrayVec<_, { 2 * TREE_BASE }>; + let left_trees; + let right_trees; + + let midpoint = (child_count + child_count % 2) / 2; + { + let mut all_summaries = child_summaries + .iter() + .chain(summaries_to_append.iter()) + .cloned(); + left_summaries = all_summaries.by_ref().take(midpoint).collect(); + right_summaries = all_summaries.collect(); + let mut all_trees = + child_trees.iter().chain(trees_to_append.iter()).cloned(); + left_trees = all_trees.by_ref().take(midpoint).collect(); + right_trees = all_trees.collect(); + } + *summary = sum(left_summaries.iter(), cx); + *child_summaries = left_summaries; + *child_trees = left_trees; + + Some(SumTree(Arc::new(Node::Internal { + height: *height, + summary: sum(right_summaries.iter(), cx), + child_summaries: right_summaries, + child_trees: right_trees, + }))) + } else { + child_summaries.extend(summaries_to_append); + child_trees.extend(trees_to_append); + None + } + } + Node::Leaf { + summary, + items, + item_summaries, + } => { + let other_node = other.0; + + let child_count = items.len() + other_node.items().len(); + if child_count > 2 * TREE_BASE { + let left_items; + let right_items; + let left_summaries; + let right_summaries: ArrayVec; + + let midpoint = (child_count + child_count % 2) / 2; + { + let mut all_items = items.iter().chain(other_node.items().iter()).cloned(); + left_items = all_items.by_ref().take(midpoint).collect(); + right_items = all_items.collect(); + + let mut all_summaries = item_summaries + .iter() + .chain(other_node.child_summaries()) + .cloned(); + left_summaries = all_summaries.by_ref().take(midpoint).collect(); + right_summaries = all_summaries.collect(); + } + *items = left_items; + *item_summaries = left_summaries; + *summary = sum(item_summaries.iter(), cx); + Some(SumTree(Arc::new(Node::Leaf { + items: right_items, + summary: sum(right_summaries.iter(), cx), + item_summaries: right_summaries, + }))) + } else { + ::add_summary(summary, other_node.summary(), cx); + items.extend(other_node.items().iter().cloned()); + item_summaries.extend(other_node.child_summaries().iter().cloned()); + None + } + } + } + } + + fn from_child_trees( + left: SumTree, + right: SumTree, + cx: ::Context<'_>, + ) -> Self { + let height = left.0.height() + 1; + let mut child_summaries = ArrayVec::new(); + child_summaries.push(left.0.summary().clone()); + child_summaries.push(right.0.summary().clone()); + let mut child_trees = ArrayVec::new(); + child_trees.push(left); + child_trees.push(right); + SumTree(Arc::new(Node::Internal { + height, + summary: sum(child_summaries.iter(), cx), + child_summaries, + child_trees, + })) + } + + fn leftmost_leaf(&self) -> &Self { + match *self.0 { + Node::Leaf { .. } => self, + Node::Internal { + ref child_trees, .. + } => child_trees.first().unwrap().leftmost_leaf(), + } + } + + fn rightmost_leaf(&self) -> &Self { + match *self.0 { + Node::Leaf { .. } => self, + Node::Internal { + ref child_trees, .. + } => child_trees.last().unwrap().rightmost_leaf(), + } + } +} + +impl PartialEq for SumTree { + fn eq(&self, other: &Self) -> bool { + self.iter().eq(other.iter()) + } +} + +impl Eq for SumTree {} + +impl SumTree { + pub fn insert_or_replace<'a, 'b>( + &'a mut self, + item: T, + cx: ::Context<'b>, + ) -> Option { + let mut replaced = None; + { + let mut cursor = self.cursor::(cx); + let mut new_tree = cursor.slice(&item.key(), Bias::Left); + if let Some(cursor_item) = cursor.item() + && cursor_item.key() == item.key() + { + replaced = Some(cursor_item.clone()); + cursor.next(); + } + new_tree.push(item, cx); + new_tree.append(cursor.suffix(), cx); + drop(cursor); + *self = new_tree + }; + replaced + } + + pub fn remove(&mut self, key: &T::Key, cx: ::Context<'_>) -> Option { + let mut removed = None; + *self = { + let mut cursor = self.cursor::(cx); + let mut new_tree = cursor.slice(key, Bias::Left); + if let Some(item) = cursor.item() + && item.key() == *key + { + removed = Some(item.clone()); + cursor.next(); + } + new_tree.append(cursor.suffix(), cx); + new_tree + }; + removed + } + + pub fn edit( + &mut self, + mut edits: Vec>, + cx: ::Context<'_>, + ) -> Vec { + if edits.is_empty() { + return Vec::new(); + } + + let mut removed = Vec::new(); + edits.sort_unstable_by_key(|item| item.key()); + + *self = { + let mut cursor = self.cursor::(cx); + let mut new_tree = SumTree::new(cx); + let mut buffered_items = Vec::new(); + + cursor.seek(&T::Key::zero(cx), Bias::Left); + for edit in edits { + let new_key = edit.key(); + let mut old_item = cursor.item(); + + if old_item + .as_ref() + .is_some_and(|old_item| old_item.key() < new_key) + { + new_tree.extend(buffered_items.drain(..), cx); + let slice = cursor.slice(&new_key, Bias::Left); + new_tree.append(slice, cx); + old_item = cursor.item(); + } + + if let Some(old_item) = old_item + && old_item.key() == new_key + { + removed.push(old_item.clone()); + cursor.next(); + } + + match edit { + Edit::Insert(item) => { + buffered_items.push(item); + } + Edit::Remove(_) => {} + } + } + + new_tree.extend(buffered_items, cx); + new_tree.append(cursor.suffix(), cx); + new_tree + }; + + removed + } + + pub fn get<'a>( + &'a self, + key: &T::Key, + cx: ::Context<'a>, + ) -> Option<&'a T> { + if let (_, _, Some(item)) = self.find_exact::(cx, key, Bias::Left) { + Some(item) + } else { + None + } + } +} + +impl Default for SumTree +where + T: Item, + S: for<'a> Summary = ()>, +{ + fn default() -> Self { + Self::new(()) + } +} + +#[derive(Clone)] +pub enum Node { + Internal { + height: u8, + summary: T::Summary, + child_summaries: ArrayVec, + child_trees: ArrayVec, { 2 * TREE_BASE }>, + }, + Leaf { + summary: T::Summary, + items: ArrayVec, + item_summaries: ArrayVec, + }, +} + +impl fmt::Debug for Node +where + T: Item + fmt::Debug, + T::Summary: fmt::Debug, +{ + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Node::Internal { + height, + summary, + child_summaries, + child_trees, + } => f + .debug_struct("Internal") + .field("height", height) + .field("summary", summary) + .field("child_summaries", child_summaries) + .field("child_trees", child_trees) + .finish(), + Node::Leaf { + summary, + items, + item_summaries, + } => f + .debug_struct("Leaf") + .field("summary", summary) + .field("items", items) + .field("item_summaries", item_summaries) + .finish(), + } + } +} + +impl Node { + fn is_leaf(&self) -> bool { + matches!(self, Node::Leaf { .. }) + } + + fn height(&self) -> u8 { + match self { + Node::Internal { height, .. } => *height, + Node::Leaf { .. } => 0, + } + } + + fn summary(&self) -> &T::Summary { + match self { + Node::Internal { summary, .. } => summary, + Node::Leaf { summary, .. } => summary, + } + } + + fn child_summaries(&self) -> &[T::Summary] { + match self { + Node::Internal { + child_summaries, .. + } => child_summaries.as_slice(), + Node::Leaf { item_summaries, .. } => item_summaries.as_slice(), + } + } + + fn child_trees(&self) -> &ArrayVec, { 2 * TREE_BASE }> { + match self { + Node::Internal { child_trees, .. } => child_trees, + Node::Leaf { .. } => panic!("Leaf nodes have no child trees"), + } + } + + fn items(&self) -> &ArrayVec { + match self { + Node::Leaf { items, .. } => items, + Node::Internal { .. } => panic!("Internal nodes have no items"), + } + } + + fn is_underflowing(&self) -> bool { + match self { + Node::Internal { child_trees, .. } => child_trees.len() < TREE_BASE, + Node::Leaf { items, .. } => items.len() < TREE_BASE, + } + } +} + +#[derive(Debug)] +pub enum Edit { + Insert(T), + Remove(T::Key), +} + +impl Edit { + fn key(&self) -> T::Key { + match self { + Edit::Insert(item) => item.key(), + Edit::Remove(key) => key.clone(), + } + } +} + +fn sum<'a, T, I>(iter: I, cx: T::Context<'_>) -> T +where + T: 'a + Summary, + I: Iterator, +{ + let mut sum = T::zero(cx); + for value in iter { + sum.add_summary(value, cx); + } + sum +} + +#[cfg(test)] +mod tests { + use super::*; + use rand::{distr::StandardUniform, prelude::*}; + use std::cmp; + + #[ctor::ctor] + fn init_logger() { + zlog::init_test(); + } + + #[test] + fn test_extend_and_push_tree() { + let mut tree1 = SumTree::default(); + tree1.extend(0..20, ()); + + let mut tree2 = SumTree::default(); + tree2.extend(50..100, ()); + + tree1.append(tree2, ()); + assert_eq!(tree1.items(()), (0..20).chain(50..100).collect::>()); + } + + #[test] + fn test_random() { + let mut starting_seed = 0; + if let Ok(value) = std::env::var("SEED") { + starting_seed = value.parse().expect("invalid SEED variable"); + } + let mut num_iterations = 100; + if let Ok(value) = std::env::var("ITERATIONS") { + num_iterations = value.parse().expect("invalid ITERATIONS variable"); + } + let num_operations = std::env::var("OPERATIONS") + .map_or(5, |o| o.parse().expect("invalid OPERATIONS variable")); + + for seed in starting_seed..(starting_seed + num_iterations) { + eprintln!("seed = {}", seed); + let mut rng = StdRng::seed_from_u64(seed); + + let rng = &mut rng; + let mut tree = SumTree::::default(); + let count = rng.random_range(0..10); + if rng.random() { + tree.extend(rng.sample_iter(StandardUniform).take(count), ()); + } else { + let items = rng + .sample_iter(StandardUniform) + .take(count) + .collect::>(); + tree.par_extend(items, ()); + } + + for _ in 0..num_operations { + let splice_end = rng.random_range(0..tree.extent::(()).0 + 1); + let splice_start = rng.random_range(0..splice_end + 1); + let count = rng.random_range(0..10); + let tree_end = tree.extent::(()); + let new_items = rng + .sample_iter(StandardUniform) + .take(count) + .collect::>(); + + let mut reference_items = tree.items(()); + reference_items.splice(splice_start..splice_end, new_items.clone()); + + tree = { + let mut cursor = tree.cursor::(()); + let mut new_tree = cursor.slice(&Count(splice_start), Bias::Right); + if rng.random() { + new_tree.extend(new_items, ()); + } else { + new_tree.par_extend(new_items, ()); + } + cursor.seek(&Count(splice_end), Bias::Right); + new_tree.append(cursor.slice(&tree_end, Bias::Right), ()); + new_tree + }; + + assert_eq!(tree.items(()), reference_items); + assert_eq!( + tree.iter().collect::>(), + tree.cursor::<()>(()).collect::>() + ); + + log::info!("tree items: {:?}", tree.items(())); + + let mut filter_cursor = + tree.filter::<_, Count>((), |summary| summary.contains_even); + let expected_filtered_items = tree + .items(()) + .into_iter() + .enumerate() + .filter(|(_, item)| (item & 1) == 0) + .collect::>(); + + let mut item_ix = if rng.random() { + filter_cursor.next(); + 0 + } else { + filter_cursor.prev(); + expected_filtered_items.len().saturating_sub(1) + }; + while item_ix < expected_filtered_items.len() { + log::info!("filter_cursor, item_ix: {}", item_ix); + let actual_item = filter_cursor.item().unwrap(); + let (reference_index, reference_item) = expected_filtered_items[item_ix]; + assert_eq!(actual_item, &reference_item); + assert_eq!(filter_cursor.start().0, reference_index); + log::info!("next"); + filter_cursor.next(); + item_ix += 1; + + while item_ix > 0 && rng.random_bool(0.2) { + log::info!("prev"); + filter_cursor.prev(); + item_ix -= 1; + + if item_ix == 0 && rng.random_bool(0.2) { + filter_cursor.prev(); + assert_eq!(filter_cursor.item(), None); + assert_eq!(filter_cursor.start().0, 0); + filter_cursor.next(); + } + } + } + assert_eq!(filter_cursor.item(), None); + + let mut before_start = false; + let mut cursor = tree.cursor::(()); + let start_pos = rng.random_range(0..=reference_items.len()); + cursor.seek(&Count(start_pos), Bias::Right); + let mut pos = rng.random_range(start_pos..=reference_items.len()); + cursor.seek_forward(&Count(pos), Bias::Right); + + for i in 0..10 { + assert_eq!(cursor.start().0, pos); + + if pos > 0 { + assert_eq!(cursor.prev_item().unwrap(), &reference_items[pos - 1]); + } else { + assert_eq!(cursor.prev_item(), None); + } + + if pos < reference_items.len() && !before_start { + assert_eq!(cursor.item().unwrap(), &reference_items[pos]); + } else { + assert_eq!(cursor.item(), None); + } + + if before_start { + assert_eq!(cursor.next_item(), reference_items.first()); + } else if pos + 1 < reference_items.len() { + assert_eq!(cursor.next_item().unwrap(), &reference_items[pos + 1]); + } else { + assert_eq!(cursor.next_item(), None); + } + + if i < 5 { + cursor.next(); + if pos < reference_items.len() { + pos += 1; + before_start = false; + } + } else { + cursor.prev(); + if pos == 0 { + before_start = true; + } + pos = pos.saturating_sub(1); + } + } + } + + for _ in 0..10 { + let end = rng.random_range(0..tree.extent::(()).0 + 1); + let start = rng.random_range(0..end + 1); + let start_bias = if rng.random() { + Bias::Left + } else { + Bias::Right + }; + let end_bias = if rng.random() { + Bias::Left + } else { + Bias::Right + }; + + let mut cursor = tree.cursor::(()); + cursor.seek(&Count(start), start_bias); + let slice = cursor.slice(&Count(end), end_bias); + + cursor.seek(&Count(start), start_bias); + let summary = cursor.summary::<_, Sum>(&Count(end), end_bias); + + assert_eq!(summary.0, slice.summary().sum); + } + } + } + + #[test] + fn test_cursor() { + // Empty tree + let tree = SumTree::::default(); + let mut cursor = tree.cursor::(()); + assert_eq!( + cursor.slice(&Count(0), Bias::Right).items(()), + Vec::::new() + ); + assert_eq!(cursor.item(), None); + assert_eq!(cursor.prev_item(), None); + assert_eq!(cursor.next_item(), None); + assert_eq!(cursor.start().sum, 0); + cursor.prev(); + assert_eq!(cursor.item(), None); + assert_eq!(cursor.prev_item(), None); + assert_eq!(cursor.next_item(), None); + assert_eq!(cursor.start().sum, 0); + cursor.next(); + assert_eq!(cursor.item(), None); + assert_eq!(cursor.prev_item(), None); + assert_eq!(cursor.next_item(), None); + assert_eq!(cursor.start().sum, 0); + + // Single-element tree + let mut tree = SumTree::::default(); + tree.extend(vec![1], ()); + let mut cursor = tree.cursor::(()); + assert_eq!( + cursor.slice(&Count(0), Bias::Right).items(()), + Vec::::new() + ); + assert_eq!(cursor.item(), Some(&1)); + assert_eq!(cursor.prev_item(), None); + assert_eq!(cursor.next_item(), None); + assert_eq!(cursor.start().sum, 0); + + cursor.next(); + assert_eq!(cursor.item(), None); + assert_eq!(cursor.prev_item(), Some(&1)); + assert_eq!(cursor.next_item(), None); + assert_eq!(cursor.start().sum, 1); + + cursor.prev(); + assert_eq!(cursor.item(), Some(&1)); + assert_eq!(cursor.prev_item(), None); + assert_eq!(cursor.next_item(), None); + assert_eq!(cursor.start().sum, 0); + + let mut cursor = tree.cursor::(()); + assert_eq!(cursor.slice(&Count(1), Bias::Right).items(()), [1]); + assert_eq!(cursor.item(), None); + assert_eq!(cursor.prev_item(), Some(&1)); + assert_eq!(cursor.next_item(), None); + assert_eq!(cursor.start().sum, 1); + + cursor.seek(&Count(0), Bias::Right); + assert_eq!( + cursor + .slice(&tree.extent::(()), Bias::Right) + .items(()), + [1] + ); + assert_eq!(cursor.item(), None); + assert_eq!(cursor.prev_item(), Some(&1)); + assert_eq!(cursor.next_item(), None); + assert_eq!(cursor.start().sum, 1); + + // Multiple-element tree + let mut tree = SumTree::default(); + tree.extend(vec![1, 2, 3, 4, 5, 6], ()); + let mut cursor = tree.cursor::(()); + + assert_eq!(cursor.slice(&Count(2), Bias::Right).items(()), [1, 2]); + assert_eq!(cursor.item(), Some(&3)); + assert_eq!(cursor.prev_item(), Some(&2)); + assert_eq!(cursor.next_item(), Some(&4)); + assert_eq!(cursor.start().sum, 3); + + cursor.next(); + assert_eq!(cursor.item(), Some(&4)); + assert_eq!(cursor.prev_item(), Some(&3)); + assert_eq!(cursor.next_item(), Some(&5)); + assert_eq!(cursor.start().sum, 6); + + cursor.next(); + assert_eq!(cursor.item(), Some(&5)); + assert_eq!(cursor.prev_item(), Some(&4)); + assert_eq!(cursor.next_item(), Some(&6)); + assert_eq!(cursor.start().sum, 10); + + cursor.next(); + assert_eq!(cursor.item(), Some(&6)); + assert_eq!(cursor.prev_item(), Some(&5)); + assert_eq!(cursor.next_item(), None); + assert_eq!(cursor.start().sum, 15); + + cursor.next(); + cursor.next(); + assert_eq!(cursor.item(), None); + assert_eq!(cursor.prev_item(), Some(&6)); + assert_eq!(cursor.next_item(), None); + assert_eq!(cursor.start().sum, 21); + + cursor.prev(); + assert_eq!(cursor.item(), Some(&6)); + assert_eq!(cursor.prev_item(), Some(&5)); + assert_eq!(cursor.next_item(), None); + assert_eq!(cursor.start().sum, 15); + + cursor.prev(); + assert_eq!(cursor.item(), Some(&5)); + assert_eq!(cursor.prev_item(), Some(&4)); + assert_eq!(cursor.next_item(), Some(&6)); + assert_eq!(cursor.start().sum, 10); + + cursor.prev(); + assert_eq!(cursor.item(), Some(&4)); + assert_eq!(cursor.prev_item(), Some(&3)); + assert_eq!(cursor.next_item(), Some(&5)); + assert_eq!(cursor.start().sum, 6); + + cursor.prev(); + assert_eq!(cursor.item(), Some(&3)); + assert_eq!(cursor.prev_item(), Some(&2)); + assert_eq!(cursor.next_item(), Some(&4)); + assert_eq!(cursor.start().sum, 3); + + cursor.prev(); + assert_eq!(cursor.item(), Some(&2)); + assert_eq!(cursor.prev_item(), Some(&1)); + assert_eq!(cursor.next_item(), Some(&3)); + assert_eq!(cursor.start().sum, 1); + + cursor.prev(); + assert_eq!(cursor.item(), Some(&1)); + assert_eq!(cursor.prev_item(), None); + assert_eq!(cursor.next_item(), Some(&2)); + assert_eq!(cursor.start().sum, 0); + + cursor.prev(); + assert_eq!(cursor.item(), None); + assert_eq!(cursor.prev_item(), None); + assert_eq!(cursor.next_item(), Some(&1)); + assert_eq!(cursor.start().sum, 0); + + cursor.next(); + assert_eq!(cursor.item(), Some(&1)); + assert_eq!(cursor.prev_item(), None); + assert_eq!(cursor.next_item(), Some(&2)); + assert_eq!(cursor.start().sum, 0); + + let mut cursor = tree.cursor::(()); + assert_eq!( + cursor + .slice(&tree.extent::(()), Bias::Right) + .items(()), + tree.items(()) + ); + assert_eq!(cursor.item(), None); + assert_eq!(cursor.prev_item(), Some(&6)); + assert_eq!(cursor.next_item(), None); + assert_eq!(cursor.start().sum, 21); + + cursor.seek(&Count(3), Bias::Right); + assert_eq!( + cursor + .slice(&tree.extent::(()), Bias::Right) + .items(()), + [4, 5, 6] + ); + assert_eq!(cursor.item(), None); + assert_eq!(cursor.prev_item(), Some(&6)); + assert_eq!(cursor.next_item(), None); + assert_eq!(cursor.start().sum, 21); + + // Seeking can bias left or right + cursor.seek(&Count(1), Bias::Left); + assert_eq!(cursor.item(), Some(&1)); + cursor.seek(&Count(1), Bias::Right); + assert_eq!(cursor.item(), Some(&2)); + + // Slicing without resetting starts from where the cursor is parked at. + cursor.seek(&Count(1), Bias::Right); + assert_eq!(cursor.slice(&Count(3), Bias::Right).items(()), vec![2, 3]); + assert_eq!(cursor.slice(&Count(6), Bias::Left).items(()), vec![4, 5]); + assert_eq!(cursor.slice(&Count(6), Bias::Right).items(()), vec![6]); + } + + #[test] + fn test_edit() { + let mut tree = SumTree::::default(); + + let removed = tree.edit(vec![Edit::Insert(1), Edit::Insert(2), Edit::Insert(0)], ()); + assert_eq!(tree.items(()), vec![0, 1, 2]); + assert_eq!(removed, Vec::::new()); + assert_eq!(tree.get(&0, ()), Some(&0)); + assert_eq!(tree.get(&1, ()), Some(&1)); + assert_eq!(tree.get(&2, ()), Some(&2)); + assert_eq!(tree.get(&4, ()), None); + + let removed = tree.edit(vec![Edit::Insert(2), Edit::Insert(4), Edit::Remove(0)], ()); + assert_eq!(tree.items(()), vec![1, 2, 4]); + assert_eq!(removed, vec![0, 2]); + assert_eq!(tree.get(&0, ()), None); + assert_eq!(tree.get(&1, ()), Some(&1)); + assert_eq!(tree.get(&2, ()), Some(&2)); + assert_eq!(tree.get(&4, ()), Some(&4)); + } + + #[test] + fn test_from_iter() { + assert_eq!( + SumTree::from_iter(0..100, ()).items(()), + (0..100).collect::>() + ); + + // Ensure `from_iter` works correctly when the given iterator restarts + // after calling `next` if `None` was already returned. + let mut ix = 0; + let iterator = std::iter::from_fn(|| { + ix = (ix + 1) % 2; + if ix == 1 { Some(1) } else { None } + }); + assert_eq!(SumTree::from_iter(iterator, ()).items(()), vec![1]); + } + + #[derive(Clone, Default, Debug)] + pub struct IntegersSummary { + count: usize, + sum: usize, + contains_even: bool, + max: u8, + } + + #[derive(Ord, PartialOrd, Default, Eq, PartialEq, Clone, Debug)] + struct Count(usize); + + #[derive(Ord, PartialOrd, Default, Eq, PartialEq, Clone, Debug)] + struct Sum(usize); + + impl Item for u8 { + type Summary = IntegersSummary; + + fn summary(&self, _cx: ()) -> Self::Summary { + IntegersSummary { + count: 1, + sum: *self as usize, + contains_even: (*self & 1) == 0, + max: *self, + } + } + } + + impl KeyedItem for u8 { + type Key = u8; + + fn key(&self) -> Self::Key { + *self + } + } + + impl ContextLessSummary for IntegersSummary { + fn zero() -> Self { + Default::default() + } + + fn add_summary(&mut self, other: &Self) { + self.count += other.count; + self.sum += other.sum; + self.contains_even |= other.contains_even; + self.max = cmp::max(self.max, other.max); + } + } + + impl Dimension<'_, IntegersSummary> for u8 { + fn zero(_cx: ()) -> Self { + Default::default() + } + + fn add_summary(&mut self, summary: &IntegersSummary, _: ()) { + *self = summary.max; + } + } + + impl Dimension<'_, IntegersSummary> for Count { + fn zero(_cx: ()) -> Self { + Default::default() + } + + fn add_summary(&mut self, summary: &IntegersSummary, _: ()) { + self.0 += summary.count; + } + } + + impl SeekTarget<'_, IntegersSummary, IntegersSummary> for Count { + fn cmp(&self, cursor_location: &IntegersSummary, _: ()) -> Ordering { + self.0.cmp(&cursor_location.count) + } + } + + impl Dimension<'_, IntegersSummary> for Sum { + fn zero(_cx: ()) -> Self { + Default::default() + } + + fn add_summary(&mut self, summary: &IntegersSummary, _: ()) { + self.0 += summary.sum; + } + } +} diff --git a/third_party/gpui_support/gpui_sum_tree/src/tree_map.rs b/third_party/gpui_support/gpui_sum_tree/src/tree_map.rs new file mode 100644 index 0000000..3e56194 --- /dev/null +++ b/third_party/gpui_support/gpui_sum_tree/src/tree_map.rs @@ -0,0 +1,521 @@ +use std::{cmp::Ordering, fmt::Debug}; + +use crate::{Bias, ContextLessSummary, Dimension, Edit, Item, KeyedItem, SeekTarget, SumTree}; + +/// A cheaply-cloneable ordered map based on a [SumTree](crate::SumTree). +#[derive(Clone, PartialEq, Eq)] +pub struct TreeMap(SumTree>) +where + K: Clone + Ord, + V: Clone; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct MapEntry { + key: K, + value: V, +} + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub struct MapKey(Option); + +impl Default for MapKey { + fn default() -> Self { + Self(None) + } +} + +#[derive(Clone, Debug)] +pub struct MapKeyRef<'a, K>(Option<&'a K>); + +impl Default for MapKeyRef<'_, K> { + fn default() -> Self { + Self(None) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TreeSet(TreeMap) +where + K: Clone + Ord; + +impl TreeMap { + pub fn from_ordered_entries(entries: impl IntoIterator) -> Self { + let tree = SumTree::from_iter( + entries + .into_iter() + .map(|(key, value)| MapEntry { key, value }), + (), + ); + Self(tree) + } + + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + + pub fn get(&self, key: &K) -> Option<&V> { + let (.., item) = self + .0 + .find::, _>((), &MapKeyRef(Some(key)), Bias::Left); + if let Some(item) = item { + if Some(key) == item.key().0.as_ref() { + Some(&item.value) + } else { + None + } + } else { + None + } + } + + pub fn insert(&mut self, key: K, value: V) { + self.0.insert_or_replace(MapEntry { key, value }, ()); + } + + pub fn extend(&mut self, iter: impl IntoIterator) { + let edits: Vec<_> = iter + .into_iter() + .map(|(key, value)| Edit::Insert(MapEntry { key, value })) + .collect(); + self.0.edit(edits, ()); + } + + pub fn clear(&mut self) { + self.0 = SumTree::default(); + } + + pub fn remove(&mut self, key: &K) -> Option { + let mut removed = None; + let mut cursor = self.0.cursor::>(()); + let key = MapKeyRef(Some(key)); + let mut new_tree = cursor.slice(&key, Bias::Left); + if key.cmp(&cursor.end(), ()) == Ordering::Equal { + removed = Some(cursor.item().unwrap().value.clone()); + cursor.next(); + } + new_tree.append(cursor.suffix(), ()); + drop(cursor); + self.0 = new_tree; + removed + } + + pub fn remove_range(&mut self, start: &impl MapSeekTarget, end: &impl MapSeekTarget) { + let start = MapSeekTargetAdaptor(start); + let end = MapSeekTargetAdaptor(end); + let mut cursor = self.0.cursor::>(()); + let mut new_tree = cursor.slice(&start, Bias::Left); + cursor.seek(&end, Bias::Left); + new_tree.append(cursor.suffix(), ()); + drop(cursor); + self.0 = new_tree; + } + + /// Returns the key-value pair with the greatest key less than or equal to the given key. + pub fn closest(&self, key: &K) -> Option<(&K, &V)> { + let mut cursor = self.0.cursor::>(()); + let key = MapKeyRef(Some(key)); + cursor.seek(&key, Bias::Right); + cursor.prev(); + cursor.item().map(|item| (&item.key, &item.value)) + } + + pub fn iter_from<'a>(&'a self, from: &K) -> impl Iterator + 'a { + let mut cursor = self.0.cursor::>(()); + let from_key = MapKeyRef(Some(from)); + cursor.seek(&from_key, Bias::Left); + + cursor.map(|map_entry| (&map_entry.key, &map_entry.value)) + } + + pub fn update(&mut self, key: &K, f: F) -> Option + where + F: FnOnce(&mut V) -> T, + { + let mut cursor = self.0.cursor::>(()); + let key = MapKeyRef(Some(key)); + let mut new_tree = cursor.slice(&key, Bias::Left); + let mut result = None; + if key.cmp(&cursor.end(), ()) == Ordering::Equal { + let mut updated = cursor.item().unwrap().clone(); + result = Some(f(&mut updated.value)); + new_tree.push(updated, ()); + cursor.next(); + } + new_tree.append(cursor.suffix(), ()); + drop(cursor); + self.0 = new_tree; + result + } + + pub fn retain bool>(&mut self, mut predicate: F) { + let mut new_map = SumTree::>::default(); + + let mut cursor = self.0.cursor::>(()); + cursor.next(); + while let Some(item) = cursor.item() { + if predicate(&item.key, &item.value) { + new_map.push(item.clone(), ()); + } + cursor.next(); + } + drop(cursor); + + self.0 = new_map; + } + + pub fn iter(&self) -> impl Iterator + '_ { + self.0.iter().map(|entry| (&entry.key, &entry.value)) + } + + pub fn values(&self) -> impl Iterator + '_ { + self.0.iter().map(|entry| &entry.value) + } + + pub fn first(&self) -> Option<(&K, &V)> { + self.0.first().map(|entry| (&entry.key, &entry.value)) + } + + pub fn last(&self) -> Option<(&K, &V)> { + self.0.last().map(|entry| (&entry.key, &entry.value)) + } + + pub fn insert_tree(&mut self, other: TreeMap) { + let edits = other + .iter() + .map(|(key, value)| { + Edit::Insert(MapEntry { + key: key.to_owned(), + value: value.to_owned(), + }) + }) + .collect(); + + self.0.edit(edits, ()); + } +} + +impl Debug for TreeMap +where + K: Clone + Debug + Ord, + V: Clone + Debug, +{ + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_map().entries(self.iter()).finish() + } +} + +#[derive(Debug)] +struct MapSeekTargetAdaptor<'a, T>(&'a T); + +impl<'a, K: Clone + Ord, T: MapSeekTarget> SeekTarget<'a, MapKey, MapKeyRef<'a, K>> + for MapSeekTargetAdaptor<'_, T> +{ + fn cmp(&self, cursor_location: &MapKeyRef, _: ()) -> Ordering { + if let Some(key) = &cursor_location.0 { + MapSeekTarget::cmp_cursor(self.0, key) + } else { + Ordering::Greater + } + } +} + +pub trait MapSeekTarget { + fn cmp_cursor(&self, cursor_location: &K) -> Ordering; +} + +impl MapSeekTarget for K { + fn cmp_cursor(&self, cursor_location: &K) -> Ordering { + self.cmp(cursor_location) + } +} + +impl Default for TreeMap +where + K: Clone + Ord, + V: Clone, +{ + fn default() -> Self { + Self(Default::default()) + } +} + +impl Item for MapEntry +where + K: Clone + Ord, + V: Clone, +{ + type Summary = MapKey; + + fn summary(&self, _cx: ()) -> Self::Summary { + self.key() + } +} + +impl KeyedItem for MapEntry +where + K: Clone + Ord, + V: Clone, +{ + type Key = MapKey; + + fn key(&self) -> Self::Key { + MapKey(Some(self.key.clone())) + } +} + +impl ContextLessSummary for MapKey +where + K: Clone, +{ + fn zero() -> Self { + Default::default() + } + + fn add_summary(&mut self, summary: &Self) { + *self = summary.clone() + } +} + +impl<'a, K> Dimension<'a, MapKey> for MapKeyRef<'a, K> +where + K: Clone + Ord, +{ + fn zero(_cx: ()) -> Self { + Default::default() + } + + fn add_summary(&mut self, summary: &'a MapKey, _: ()) { + self.0 = summary.0.as_ref(); + } +} + +impl<'a, K> SeekTarget<'a, MapKey, MapKeyRef<'a, K>> for MapKeyRef<'_, K> +where + K: Clone + Ord, +{ + fn cmp(&self, cursor_location: &MapKeyRef, _: ()) -> Ordering { + Ord::cmp(&self.0, &cursor_location.0) + } +} + +impl Default for TreeSet +where + K: Clone + Ord, +{ + fn default() -> Self { + Self(Default::default()) + } +} + +impl TreeSet +where + K: Clone + Ord, +{ + pub fn from_ordered_entries(entries: impl IntoIterator) -> Self { + Self(TreeMap::from_ordered_entries( + entries.into_iter().map(|key| (key, ())), + )) + } + + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + + pub fn insert(&mut self, key: K) { + self.0.insert(key, ()); + } + + pub fn remove(&mut self, key: &K) -> bool { + self.0.remove(key).is_some() + } + + pub fn extend(&mut self, iter: impl IntoIterator) { + self.0.extend(iter.into_iter().map(|key| (key, ()))); + } + + pub fn contains(&self, key: &K) -> bool { + self.0.get(key).is_some() + } + + pub fn iter(&self) -> impl Iterator + '_ { + self.0.iter().map(|(k, _)| k) + } + + pub fn iter_from<'a>(&'a self, key: &K) -> impl Iterator + 'a { + self.0.iter_from(key).map(move |(k, _)| k) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_basic() { + let mut map = TreeMap::default(); + assert_eq!(map.iter().collect::>(), vec![]); + + map.insert(3, "c"); + assert_eq!(map.get(&3), Some(&"c")); + assert_eq!(map.iter().collect::>(), vec![(&3, &"c")]); + + map.insert(1, "a"); + assert_eq!(map.get(&1), Some(&"a")); + assert_eq!(map.iter().collect::>(), vec![(&1, &"a"), (&3, &"c")]); + + map.insert(2, "b"); + assert_eq!(map.get(&2), Some(&"b")); + assert_eq!(map.get(&1), Some(&"a")); + assert_eq!(map.get(&3), Some(&"c")); + assert_eq!( + map.iter().collect::>(), + vec![(&1, &"a"), (&2, &"b"), (&3, &"c")] + ); + + assert_eq!(map.closest(&0), None); + assert_eq!(map.closest(&1), Some((&1, &"a"))); + assert_eq!(map.closest(&10), Some((&3, &"c"))); + + map.remove(&2); + assert_eq!(map.get(&2), None); + assert_eq!(map.iter().collect::>(), vec![(&1, &"a"), (&3, &"c")]); + + assert_eq!(map.closest(&2), Some((&1, &"a"))); + + map.remove(&3); + assert_eq!(map.get(&3), None); + assert_eq!(map.iter().collect::>(), vec![(&1, &"a")]); + + map.remove(&1); + assert_eq!(map.get(&1), None); + assert_eq!(map.iter().collect::>(), vec![]); + + map.insert(4, "d"); + map.insert(5, "e"); + map.insert(6, "f"); + map.retain(|key, _| *key % 2 == 0); + assert_eq!(map.iter().collect::>(), vec![(&4, &"d"), (&6, &"f")]); + } + + #[test] + fn test_iter_from() { + let mut map = TreeMap::default(); + + map.insert("a", 1); + map.insert("b", 2); + map.insert("baa", 3); + map.insert("baaab", 4); + map.insert("c", 5); + + let result = map + .iter_from(&"ba") + .take_while(|(key, _)| key.starts_with("ba")) + .collect::>(); + + assert_eq!(result.len(), 2); + assert!(result.iter().any(|(k, _)| k == &&"baa")); + assert!(result.iter().any(|(k, _)| k == &&"baaab")); + + let result = map + .iter_from(&"c") + .take_while(|(key, _)| key.starts_with("c")) + .collect::>(); + + assert_eq!(result.len(), 1); + assert!(result.iter().any(|(k, _)| k == &&"c")); + } + + #[test] + fn test_insert_tree() { + let mut map = TreeMap::default(); + map.insert("a", 1); + map.insert("b", 2); + map.insert("c", 3); + + let mut other = TreeMap::default(); + other.insert("a", 2); + other.insert("b", 2); + other.insert("d", 4); + + map.insert_tree(other); + + assert_eq!(map.iter().count(), 4); + assert_eq!(map.get(&"a"), Some(&2)); + assert_eq!(map.get(&"b"), Some(&2)); + assert_eq!(map.get(&"c"), Some(&3)); + assert_eq!(map.get(&"d"), Some(&4)); + } + + #[test] + fn test_extend() { + let mut map = TreeMap::default(); + map.insert("a", 1); + map.insert("b", 2); + map.insert("c", 3); + map.extend([("a", 2), ("b", 2), ("d", 4)]); + assert_eq!(map.iter().count(), 4); + assert_eq!(map.get(&"a"), Some(&2)); + assert_eq!(map.get(&"b"), Some(&2)); + assert_eq!(map.get(&"c"), Some(&3)); + assert_eq!(map.get(&"d"), Some(&4)); + } + + #[test] + fn test_remove_between_and_path_successor() { + use std::path::{Path, PathBuf}; + + #[derive(Debug)] + pub struct PathDescendants<'a>(&'a Path); + + impl MapSeekTarget for PathDescendants<'_> { + fn cmp_cursor(&self, key: &PathBuf) -> Ordering { + if key.starts_with(self.0) { + Ordering::Greater + } else { + self.0.cmp(key) + } + } + } + + let mut map = TreeMap::default(); + + map.insert(PathBuf::from("a"), 1); + map.insert(PathBuf::from("a/a"), 1); + map.insert(PathBuf::from("b"), 2); + map.insert(PathBuf::from("b/a/a"), 3); + map.insert(PathBuf::from("b/a/a/a/b"), 4); + map.insert(PathBuf::from("c"), 5); + map.insert(PathBuf::from("c/a"), 6); + + map.remove_range( + &PathBuf::from("b/a"), + &PathDescendants(&PathBuf::from("b/a")), + ); + + assert_eq!(map.get(&PathBuf::from("a")), Some(&1)); + assert_eq!(map.get(&PathBuf::from("a/a")), Some(&1)); + assert_eq!(map.get(&PathBuf::from("b")), Some(&2)); + assert_eq!(map.get(&PathBuf::from("b/a/a")), None); + assert_eq!(map.get(&PathBuf::from("b/a/a/a/b")), None); + assert_eq!(map.get(&PathBuf::from("c")), Some(&5)); + assert_eq!(map.get(&PathBuf::from("c/a")), Some(&6)); + + map.remove_range(&PathBuf::from("c"), &PathDescendants(&PathBuf::from("c"))); + + assert_eq!(map.get(&PathBuf::from("a")), Some(&1)); + assert_eq!(map.get(&PathBuf::from("a/a")), Some(&1)); + assert_eq!(map.get(&PathBuf::from("b")), Some(&2)); + assert_eq!(map.get(&PathBuf::from("c")), None); + assert_eq!(map.get(&PathBuf::from("c/a")), None); + + map.remove_range(&PathBuf::from("a"), &PathDescendants(&PathBuf::from("a"))); + + assert_eq!(map.get(&PathBuf::from("a")), None); + assert_eq!(map.get(&PathBuf::from("a/a")), None); + assert_eq!(map.get(&PathBuf::from("b")), Some(&2)); + + map.remove_range(&PathBuf::from("b"), &PathDescendants(&PathBuf::from("b"))); + + assert_eq!(map.get(&PathBuf::from("b")), None); + } +} diff --git a/third_party/gpui_support/gpui_util/Cargo.toml b/third_party/gpui_support/gpui_util/Cargo.toml new file mode 100644 index 0000000..94da83a --- /dev/null +++ b/third_party/gpui_support/gpui_util/Cargo.toml @@ -0,0 +1,194 @@ +# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO +# +# When uploading crates to the registry Cargo will automatically +# "normalize" Cargo.toml files for maximal compatibility +# with all versions of Cargo and also rewrite `path` dependencies +# to registry (e.g., crates.io) dependencies. +# +# Local copy keeps the registry manifest shape and narrows log features so +# GPUI and Servo can share the same workspace dependency graph. + +[package] +edition = "2024" +name = "gpui_util" +version = "0.2.2" +build = false +publish = true +autolib = false +autobins = false +autoexamples = false +autotests = false +autobenches = false +description = "A collection of utility structs and functions used by Zed and GPUI" +readme = false +license = "Apache-2.0" +resolver = "2" + +[features] +test-support = [ + "git2", + "rand", + "util_macros", +] + +[lib] +name = "gpui_util" +path = "src/util.rs" +doctest = true + +[dependencies.anyhow] +version = "1.0.86" + +[dependencies.async-fs] +version = "2.1" + +[dependencies.async_zip] +version = "0.0.17" +features = [ + "deflate", + "deflate64", +] + +[dependencies.collections] +version = "0.2.2" +package = "gpui_collections" + +[dependencies.dirs] +version = "4.0" + +[dependencies.dunce] +version = "1.0" + +[dependencies.futures] +version = "0.3" + +[dependencies.futures-lite] +version = "1.13" + +[dependencies.git2] +version = "0.20.1" +optional = true +default-features = false + +[dependencies.globset] +version = "0.4" + +[dependencies.itertools] +version = "0.14.0" + +[dependencies.log] +version = "0.4.16" + +[dependencies.rand] +version = "0.9" +optional = true + +[dependencies.regex] +version = "1.5" + +[dependencies.rust-embed] +version = "8.4" +features = ["include-exclude"] + +[dependencies.schemars] +version = "1.0" +features = ["indexmap2"] + +[dependencies.serde] +version = "1.0.221" +features = [ + "derive", + "rc", +] + +[dependencies.serde_json] +version = "1.0.144" +features = [ + "preserve_order", + "raw_value", +] + +[dependencies.serde_json_lenient] +version = "0.2" +features = [ + "preserve_order", + "raw_value", +] + +[dependencies.shlex] +version = "1.3.0" + +[dependencies.smol] +version = "2.0" + +[dependencies.take-until] +version = "0.2.0" + +[dependencies.tempfile] +version = "3.20.0" + +[dependencies.unicase] +version = "2.6" + +[dependencies.util_macros] +version = "0.2.2" +optional = true +package = "gpui_util_macros" + +[dependencies.walkdir] +version = "2.5" + +[dependencies.which] +version = "6.0.0" + +[dev-dependencies.git2] +version = "0.20.1" +default-features = false + +[dev-dependencies.indoc] +version = "2" + +[dev-dependencies.pretty_assertions] +version = "1.3.0" +features = ["unstable"] + +[dev-dependencies.rand] +version = "0.9" + +[dev-dependencies.util_macros] +version = "0.2.2" +package = "gpui_util_macros" + +[target."cfg(unix)".dependencies.command-fds] +version = "0.3.1" + +[target."cfg(unix)".dependencies.libc] +version = "0.2" + +[target."cfg(unix)".dependencies.nix] +version = "0.29" +features = ["user"] + +[target."cfg(windows)".dependencies.tendril] +version = "0.4.3" + +[lints.clippy] +dbg_macro = "deny" +declare_interior_mutable_const = "deny" +disallowed_methods = "deny" +large_enum_variant = "allow" +let_underscore_future = "allow" +nonminimal_bool = "allow" +redundant_clone = "deny" +single_range_in_vec_init = "allow" +todo = "deny" +too_many_arguments = "allow" +type_complexity = "allow" + +[lints.clippy.style] +level = "allow" +priority = -1 + +[lints.rust.unexpected_cfgs] +level = "allow" +priority = 0 diff --git a/third_party/gpui_support/gpui_util/LICENSE-APACHE b/third_party/gpui_support/gpui_util/LICENSE-APACHE new file mode 100644 index 0000000..461a0fe --- /dev/null +++ b/third_party/gpui_support/gpui_util/LICENSE-APACHE @@ -0,0 +1,222 @@ +Copyright 2022 - 2025 Zed Industries, Inc. + + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + + http://www.apache.org/licenses/LICENSE-2.0 + + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + + + +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + + 1. Definitions. + + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + + END OF TERMS AND CONDITIONS diff --git a/third_party/gpui_support/gpui_util/src/arc_cow.rs b/third_party/gpui_support/gpui_util/src/arc_cow.rs new file mode 100644 index 0000000..4104067 --- /dev/null +++ b/third_party/gpui_support/gpui_util/src/arc_cow.rs @@ -0,0 +1,141 @@ +use std::{ + borrow::Cow, + cmp::Ordering, + fmt::{self, Debug}, + hash::{Hash, Hasher}, + sync::Arc, +}; + +pub enum ArcCow<'a, T: ?Sized> { + Borrowed(&'a T), + Owned(Arc), +} + +impl PartialEq for ArcCow<'_, T> { + fn eq(&self, other: &Self) -> bool { + let a = self.as_ref(); + let b = other.as_ref(); + a == b + } +} + +impl PartialOrd for ArcCow<'_, T> { + fn partial_cmp(&self, other: &Self) -> Option { + self.as_ref().partial_cmp(other.as_ref()) + } +} + +impl Ord for ArcCow<'_, T> { + fn cmp(&self, other: &Self) -> Ordering { + self.as_ref().cmp(other.as_ref()) + } +} + +impl Eq for ArcCow<'_, T> {} + +impl Hash for ArcCow<'_, T> { + fn hash(&self, state: &mut H) { + match self { + Self::Borrowed(borrowed) => Hash::hash(borrowed, state), + Self::Owned(owned) => Hash::hash(&**owned, state), + } + } +} + +impl Clone for ArcCow<'_, T> { + fn clone(&self) -> Self { + match self { + Self::Borrowed(borrowed) => Self::Borrowed(borrowed), + Self::Owned(owned) => Self::Owned(owned.clone()), + } + } +} + +impl<'a, T: ?Sized> From<&'a T> for ArcCow<'a, T> { + fn from(s: &'a T) -> Self { + Self::Borrowed(s) + } +} + +impl From> for ArcCow<'_, T> { + fn from(s: Arc) -> Self { + Self::Owned(s) + } +} + +impl From<&'_ Arc> for ArcCow<'_, T> { + fn from(s: &'_ Arc) -> Self { + Self::Owned(s.clone()) + } +} + +impl From for ArcCow<'_, str> { + fn from(value: String) -> Self { + Self::Owned(value.into()) + } +} + +impl From<&String> for ArcCow<'_, str> { + fn from(value: &String) -> Self { + Self::Owned(value.clone().into()) + } +} + +impl<'a> From> for ArcCow<'a, str> { + fn from(value: Cow<'a, str>) -> Self { + match value { + Cow::Borrowed(borrowed) => Self::Borrowed(borrowed), + Cow::Owned(owned) => Self::Owned(owned.into()), + } + } +} + +impl From> for ArcCow<'_, [T]> { + fn from(vec: Vec) -> Self { + ArcCow::Owned(Arc::from(vec)) + } +} + +impl<'a> From<&'a str> for ArcCow<'a, [u8]> { + fn from(s: &'a str) -> Self { + ArcCow::Borrowed(s.as_bytes()) + } +} + +impl std::borrow::Borrow for ArcCow<'_, T> { + fn borrow(&self) -> &T { + match self { + ArcCow::Borrowed(borrowed) => borrowed, + ArcCow::Owned(owned) => owned.as_ref(), + } + } +} + +impl std::ops::Deref for ArcCow<'_, T> { + type Target = T; + + fn deref(&self) -> &Self::Target { + match self { + ArcCow::Borrowed(s) => s, + ArcCow::Owned(s) => s.as_ref(), + } + } +} + +impl AsRef for ArcCow<'_, T> { + fn as_ref(&self) -> &T { + match self { + ArcCow::Borrowed(borrowed) => borrowed, + ArcCow::Owned(owned) => owned.as_ref(), + } + } +} + +impl Debug for ArcCow<'_, T> { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + ArcCow::Borrowed(borrowed) => Debug::fmt(borrowed, f), + ArcCow::Owned(owned) => Debug::fmt(&**owned, f), + } + } +} diff --git a/third_party/gpui_support/gpui_util/src/archive.rs b/third_party/gpui_support/gpui_util/src/archive.rs new file mode 100644 index 0000000..9b58b16 --- /dev/null +++ b/third_party/gpui_support/gpui_util/src/archive.rs @@ -0,0 +1,253 @@ +use std::path::Path; + +use anyhow::{Context as _, Result}; +use async_zip::base::read; +#[cfg(not(windows))] +use futures::AsyncSeek; +use futures::{AsyncRead, io::BufReader}; + +#[cfg(windows)] +pub async fn extract_zip(destination: &Path, reader: R) -> Result<()> { + let mut reader = read::stream::ZipFileReader::new(BufReader::new(reader)); + + let destination = &destination + .canonicalize() + .unwrap_or_else(|_| destination.to_path_buf()); + + while let Some(mut item) = reader.next_with_entry().await? { + let entry_reader = item.reader_mut(); + let entry = entry_reader.entry(); + let path = destination.join( + entry + .filename() + .as_str() + .context("reading zip entry file name")?, + ); + + if entry + .dir() + .with_context(|| format!("reading zip entry metadata for path {path:?}"))? + { + std::fs::create_dir_all(&path) + .with_context(|| format!("creating directory {path:?}"))?; + } else { + let parent_dir = path + .parent() + .with_context(|| format!("no parent directory for {path:?}"))?; + std::fs::create_dir_all(parent_dir) + .with_context(|| format!("creating parent directory {parent_dir:?}"))?; + let mut file = smol::fs::File::create(&path) + .await + .with_context(|| format!("creating file {path:?}"))?; + futures::io::copy(entry_reader, &mut file) + .await + .with_context(|| format!("extracting into file {path:?}"))?; + } + + reader = item.skip().await.context("reading next zip entry")?; + } + + Ok(()) +} + +#[cfg(not(windows))] +pub async fn extract_zip(destination: &Path, reader: R) -> Result<()> { + // Unix needs file permissions copied when extracting. + // This is only possible to do when a reader impls `AsyncSeek` and `seek::ZipFileReader` is used. + // `stream::ZipFileReader` also has the `unix_permissions` method, but it will always return `Some(0)`. + // + // A typical `reader` comes from a streaming network response, so cannot be sought right away, + // and reading the entire archive into the memory seems wasteful. + // + // So, save the stream into a temporary file first and then get it read with a seeking reader. + let mut file = async_fs::File::from(tempfile::tempfile().context("creating a temporary file")?); + futures::io::copy(&mut BufReader::new(reader), &mut file) + .await + .context("saving archive contents into the temporary file")?; + extract_seekable_zip(destination, file).await +} + +#[cfg(not(windows))] +pub async fn extract_seekable_zip( + destination: &Path, + reader: R, +) -> Result<()> { + let mut reader = read::seek::ZipFileReader::new(BufReader::new(reader)) + .await + .context("reading the zip archive")?; + let destination = &destination + .canonicalize() + .unwrap_or_else(|_| destination.to_path_buf()); + for (i, entry) in reader.file().entries().to_vec().into_iter().enumerate() { + let path = destination.join( + entry + .filename() + .as_str() + .context("reading zip entry file name")?, + ); + + if entry + .dir() + .with_context(|| format!("reading zip entry metadata for path {path:?}"))? + { + std::fs::create_dir_all(&path) + .with_context(|| format!("creating directory {path:?}"))?; + } else { + let parent_dir = path + .parent() + .with_context(|| format!("no parent directory for {path:?}"))?; + std::fs::create_dir_all(parent_dir) + .with_context(|| format!("creating parent directory {parent_dir:?}"))?; + let mut file = smol::fs::File::create(&path) + .await + .with_context(|| format!("creating file {path:?}"))?; + let mut entry_reader = reader + .reader_with_entry(i) + .await + .with_context(|| format!("reading entry for path {path:?}"))?; + futures::io::copy(&mut entry_reader, &mut file) + .await + .with_context(|| format!("extracting into file {path:?}"))?; + + if let Some(perms) = entry.unix_permissions() { + use std::os::unix::fs::PermissionsExt; + let permissions = std::fs::Permissions::from_mode(u32::from(perms)); + file.set_permissions(permissions) + .await + .with_context(|| format!("setting permissions for file {path:?}"))?; + } + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use async_zip::ZipEntryBuilder; + use async_zip::base::write::ZipFileWriter; + use futures::{AsyncSeek, AsyncWriteExt}; + use smol::io::Cursor; + use tempfile::TempDir; + + use super::*; + + async fn compress_zip(src_dir: &Path, dst: &Path) -> Result<()> { + let mut out = smol::fs::File::create(dst).await?; + let mut writer = ZipFileWriter::new(&mut out); + + for entry in walkdir::WalkDir::new(src_dir) { + let entry = entry?; + let path = entry.path(); + + if path.is_dir() { + continue; + } + + let relative_path = path.strip_prefix(src_dir)?; + let data = smol::fs::read(&path).await?; + + let filename = relative_path.display().to_string(); + + #[cfg(unix)] + { + let mut builder = + ZipEntryBuilder::new(filename.into(), async_zip::Compression::Deflate); + use std::os::unix::fs::PermissionsExt; + let metadata = std::fs::metadata(path)?; + let perms = metadata.permissions().mode() as u16; + builder = builder.unix_permissions(perms); + writer.write_entry_whole(builder, &data).await?; + } + #[cfg(not(unix))] + { + let builder = + ZipEntryBuilder::new(filename.into(), async_zip::Compression::Deflate); + writer.write_entry_whole(builder, &data).await?; + } + } + + writer.close().await?; + out.flush().await?; + + Ok(()) + } + + #[track_caller] + fn assert_file_content(path: &Path, content: &str) { + assert!(path.exists(), "file not found: {:?}", path); + let actual = std::fs::read_to_string(path).unwrap(); + assert_eq!(actual, content); + } + + #[track_caller] + fn make_test_data() -> TempDir { + let dir = tempfile::tempdir().unwrap(); + let dst = dir.path(); + + std::fs::write(dst.join("test"), "Hello world.").unwrap(); + std::fs::create_dir_all(dst.join("foo/bar")).unwrap(); + std::fs::write(dst.join("foo/bar.txt"), "Foo bar.").unwrap(); + std::fs::write(dst.join("foo/dar.md"), "Bar dar.").unwrap(); + std::fs::write(dst.join("foo/bar/dar你好.txt"), "你好世界").unwrap(); + + dir + } + + async fn read_archive(path: &Path) -> impl AsyncRead + AsyncSeek + Unpin { + let data = smol::fs::read(&path).await.unwrap(); + Cursor::new(data) + } + + #[test] + fn test_extract_zip() { + let test_dir = make_test_data(); + let zip_file = test_dir.path().join("test.zip"); + + smol::block_on(async { + compress_zip(test_dir.path(), &zip_file).await.unwrap(); + let reader = read_archive(&zip_file).await; + + let dir = tempfile::tempdir().unwrap(); + let dst = dir.path(); + extract_zip(dst, reader).await.unwrap(); + + assert_file_content(&dst.join("test"), "Hello world."); + assert_file_content(&dst.join("foo/bar.txt"), "Foo bar."); + assert_file_content(&dst.join("foo/dar.md"), "Bar dar."); + assert_file_content(&dst.join("foo/bar/dar你好.txt"), "你好世界"); + }); + } + + #[cfg(unix)] + #[test] + fn test_extract_zip_preserves_executable_permissions() { + use std::os::unix::fs::PermissionsExt; + + smol::block_on(async { + let test_dir = tempfile::tempdir().unwrap(); + let executable_path = test_dir.path().join("my_script"); + + // Create an executable file + std::fs::write(&executable_path, "#!/bin/bash\necho 'Hello'").unwrap(); + let mut perms = std::fs::metadata(&executable_path).unwrap().permissions(); + perms.set_mode(0o755); // rwxr-xr-x + std::fs::set_permissions(&executable_path, perms).unwrap(); + + // Create zip + let zip_file = test_dir.path().join("test.zip"); + compress_zip(test_dir.path(), &zip_file).await.unwrap(); + + // Extract to new location + let extract_dir = tempfile::tempdir().unwrap(); + let reader = read_archive(&zip_file).await; + extract_zip(extract_dir.path(), reader).await.unwrap(); + + // Check permissions are preserved + let extracted_path = extract_dir.path().join("my_script"); + assert!(extracted_path.exists()); + let extracted_perms = std::fs::metadata(&extracted_path).unwrap().permissions(); + assert_eq!(extracted_perms.mode() & 0o777, 0o755); + }); + } +} diff --git a/third_party/gpui_support/gpui_util/src/command.rs b/third_party/gpui_support/gpui_util/src/command.rs new file mode 100644 index 0000000..85e2234 --- /dev/null +++ b/third_party/gpui_support/gpui_util/src/command.rs @@ -0,0 +1,32 @@ +use std::ffi::OsStr; + +#[cfg(target_os = "windows")] +const CREATE_NO_WINDOW: u32 = 0x0800_0000_u32; + +#[cfg(target_os = "windows")] +pub fn new_std_command(program: impl AsRef) -> std::process::Command { + use std::os::windows::process::CommandExt; + + let mut command = std::process::Command::new(program); + command.creation_flags(CREATE_NO_WINDOW); + command +} + +#[cfg(not(target_os = "windows"))] +pub fn new_std_command(program: impl AsRef) -> std::process::Command { + std::process::Command::new(program) +} + +#[cfg(target_os = "windows")] +pub fn new_smol_command(program: impl AsRef) -> smol::process::Command { + use smol::process::windows::CommandExt; + + let mut command = smol::process::Command::new(program); + command.creation_flags(CREATE_NO_WINDOW); + command +} + +#[cfg(not(target_os = "windows"))] +pub fn new_smol_command(program: impl AsRef) -> smol::process::Command { + smol::process::Command::new(program) +} diff --git a/third_party/gpui_support/gpui_util/src/fs.rs b/third_party/gpui_support/gpui_util/src/fs.rs new file mode 100644 index 0000000..60aab4a --- /dev/null +++ b/third_party/gpui_support/gpui_util/src/fs.rs @@ -0,0 +1,111 @@ +use crate::ResultExt; +use anyhow::{Result, bail}; +use async_fs as fs; +use futures_lite::StreamExt; +use std::path::{Path, PathBuf}; + +/// Removes all files and directories matching the given predicate +pub async fn remove_matching(dir: &Path, predicate: F) +where + F: Fn(&Path) -> bool, +{ + if let Some(mut entries) = fs::read_dir(dir).await.log_err() { + while let Some(entry) = entries.next().await { + if let Some(entry) = entry.log_err() { + let entry_path = entry.path(); + if predicate(entry_path.as_path()) + && let Ok(metadata) = fs::metadata(&entry_path).await + { + if metadata.is_file() { + fs::remove_file(&entry_path).await.log_err(); + } else { + fs::remove_dir_all(&entry_path).await.log_err(); + } + } + } + } + } +} + +pub async fn collect_matching(dir: &Path, predicate: F) -> Vec +where + F: Fn(&Path) -> bool, +{ + let mut matching = vec![]; + + if let Some(mut entries) = fs::read_dir(dir).await.log_err() { + while let Some(entry) = entries.next().await { + if let Some(entry) = entry.log_err() + && predicate(entry.path().as_path()) + { + matching.push(entry.path()); + } + } + } + + matching +} + +pub async fn find_file_name_in_dir(dir: &Path, predicate: F) -> Option +where + F: Fn(&str) -> bool, +{ + if let Some(mut entries) = fs::read_dir(dir).await.log_err() { + while let Some(entry) = entries.next().await { + if let Some(entry) = entry.log_err() { + let entry_path = entry.path(); + + if let Some(file_name) = entry_path + .file_name() + .map(|file_name| file_name.to_string_lossy()) + && predicate(&file_name) + { + return Some(entry_path); + } + } + } + } + + None +} + +pub async fn move_folder_files_to_folder>( + source_path: P, + target_path: P, +) -> Result<()> { + if !target_path.as_ref().is_dir() { + bail!("Folder not found or is not a directory"); + } + + let mut entries = fs::read_dir(source_path.as_ref()).await?; + while let Some(entry) = entries.next().await { + let entry = entry?; + let old_path = entry.path(); + let new_path = target_path.as_ref().join(entry.file_name()); + + fs::rename(&old_path, &new_path).await?; + } + + fs::remove_dir(source_path).await?; + + Ok(()) +} + +#[cfg(unix)] +/// Set the permissions for the given path so that the file becomes executable. +/// This is a noop for non-unix platforms. +pub async fn make_file_executable(path: &Path) -> std::io::Result<()> { + fs::set_permissions( + path, + ::from_mode(0o755), + ) + .await +} + +#[cfg(not(unix))] +#[allow(clippy::unused_async)] +/// Set the permissions for the given path so that the file becomes executable. +/// This is a noop for non-unix platforms. +pub async fn make_file_executable(_path: &Path) -> std::io::Result<()> { + Ok(()) +} diff --git a/third_party/gpui_support/gpui_util/src/markdown.rs b/third_party/gpui_support/gpui_util/src/markdown.rs new file mode 100644 index 0000000..303dbe0 --- /dev/null +++ b/third_party/gpui_support/gpui_util/src/markdown.rs @@ -0,0 +1,268 @@ +use std::fmt::{Display, Formatter}; + +/// Indicates that the wrapped `String` is markdown text. +#[derive(Debug, Clone)] +pub struct MarkdownString(pub String); + +impl Display for MarkdownString { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +/// Escapes markdown special characters in markdown text blocks. Markdown code blocks follow +/// different rules and `MarkdownInlineCode` or `MarkdownCodeBlock` should be used in that case. +/// +/// Also escapes the following markdown extensions: +/// +/// * `^` for superscripts +/// * `$` for inline math +/// * `~` for strikethrough +/// +/// Escape of some characters is unnecessary, because while they are involved in markdown syntax, +/// the other characters involved are escaped: +/// +/// * `!`, `]`, `(`, and `)` are used in link syntax, but `[` is escaped so these are parsed as +/// plaintext. +/// +/// * `;` is used in HTML entity syntax, but `&` is escaped, so they are parsed as plaintext. +/// +/// TODO: There is one escape this doesn't do currently. Period after numbers at the start of the +/// line (`[0-9]*\.`) should also be escaped to avoid it being interpreted as a list item. +pub struct MarkdownEscaped<'a>(pub &'a str); + +/// Implements `Display` to format markdown inline code (wrapped in backticks), handling code that +/// contains backticks and spaces. All whitespace is treated as a single space character. For text +/// that does not contain whitespace other than ' ', this escaping roundtrips through +/// pulldown-cmark. +/// +/// When used in tables, `|` should be escaped like `\|` in the text provided to this function. +pub struct MarkdownInlineCode<'a>(pub &'a str); + +/// Implements `Display` to format markdown code blocks, wrapped in 3 or more backticks as needed. +pub struct MarkdownCodeBlock<'a> { + pub tag: &'a str, + pub text: &'a str, +} + +impl Display for MarkdownEscaped<'_> { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + let mut start_of_unescaped = None; + for (ix, c) in self.0.char_indices() { + match c { + // Always escaped. + '\\' | '`' | '*' | '_' | '[' | '^' | '$' | '~' | '&' | + // TODO: these only need to be escaped when they are the first non-whitespace + // character of the line of a block. There should probably be both an `escape_block` + // which does this and an `escape_inline` method which does not escape these. + '#' | '+' | '=' | '-' => { + match start_of_unescaped { + None => {} + Some(start_of_unescaped) => { + write!(formatter, "{}", &self.0[start_of_unescaped..ix])?; + } + } + write!(formatter, "\\")?; + // Can include this char in the "unescaped" text since a + // backslash was just emitted. + start_of_unescaped = Some(ix); + } + // Escaped since `<` is used in opening HTML tags. `<` is used since Markdown + // supports HTML entities, and this allows the text to be used directly in HTML. + '<' => { + match start_of_unescaped { + None => {} + Some(start_of_unescaped) => { + write!(formatter, "{}", &self.0[start_of_unescaped..ix])?; + } + } + write!(formatter, "<")?; + start_of_unescaped = None; + } + // Escaped since `>` is used for blockquotes. `>` is used since Markdown supports + // HTML entities, and this allows the text to be used directly in HTML. + '>' => { + match start_of_unescaped { + None => {} + Some(start_of_unescaped) => { + write!(formatter, "{}", &self.0[start_of_unescaped..ix])?; + } + } + write!(formatter, ">")?; + start_of_unescaped = None; + } + _ => { + if start_of_unescaped.is_none() { + start_of_unescaped = Some(ix); + } + } + } + } + if let Some(start_of_unescaped) = start_of_unescaped { + write!(formatter, "{}", &self.0[start_of_unescaped..])?; + } + Ok(()) + } +} + +impl Display for MarkdownInlineCode<'_> { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + // Apache License 2.0, same as this crate. + // + // Copied from `pulldown-cmark-to-cmark-20.0.0` with modifications: + // + // * Handling of all whitespace. pulldown-cmark-to-cmark is anticipating + // `Code` events parsed by pulldown-cmark. + // + // https://github.com/Byron/pulldown-cmark-to-cmark/blob/3c850de2d3d1d79f19ca5f375e1089a653cf3ff7/src/lib.rs#L290 + + let mut all_whitespace = true; + let text = self + .0 + .chars() + .map(|c| { + if c.is_whitespace() { + ' ' + } else { + all_whitespace = false; + c + } + }) + .collect::(); + + // When inline code has leading and trailing ' ' characters, additional space is needed + // to escape it, unless all characters are space. + if all_whitespace { + write!(formatter, "`{text}`") + } else { + // More backticks are needed to delimit the inline code than the maximum number of + // backticks in a consecutive run. + let backticks = "`".repeat(count_max_consecutive_chars(&text, '`') + 1); + let space = match text.as_bytes() { + &[b'`', ..] | &[.., b'`'] => " ", // Space needed to separate backtick. + &[b' ', .., b' '] => " ", // Space needed to escape inner space. + _ => "", // No space needed. + }; + write!(formatter, "{backticks}{space}{text}{space}{backticks}") + } + } +} + +impl Display for MarkdownCodeBlock<'_> { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + let tag = self.tag; + let text = self.text; + let backticks = "`".repeat(3.max(count_max_consecutive_chars(text, '`') + 1)); + write!(formatter, "{backticks}{tag}\n{text}\n{backticks}\n") + } +} + +// Copied from `pulldown-cmark-to-cmark-20.0.0` with changed names. +// https://github.com/Byron/pulldown-cmark-to-cmark/blob/3c850de2d3d1d79f19ca5f375e1089a653cf3ff7/src/lib.rs#L1063 +// Apache License 2.0, same as this code. +fn count_max_consecutive_chars(text: &str, search: char) -> usize { + let mut in_search_chars = false; + let mut max_count = 0; + let mut cur_count = 0; + + for ch in text.chars() { + if ch == search { + cur_count += 1; + in_search_chars = true; + } else if in_search_chars { + max_count = max_count.max(cur_count); + cur_count = 0; + in_search_chars = false; + } + } + max_count.max(cur_count) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_markdown_escaped() { + let input = r#" + # Heading + + Another heading + === + + Another heading variant + --- + + Paragraph with [link](https://example.com) and `code`, *emphasis*, and ~strikethrough~. + + ``` + code block + ``` + + List with varying leaders: + - Item 1 + * Item 2 + + Item 3 + + Some math: $`\sqrt{3x-1}+(1+x)^2`$ + + HTML entity:   + "#; + + let expected = r#" + \# Heading + + Another heading + \=\=\= + + Another heading variant + \-\-\- + + Paragraph with \[link](https://example.com) and \`code\`, \*emphasis\*, and \~strikethrough\~. + + \`\`\` + code block + \`\`\` + + List with varying leaders: + \- Item 1 + \* Item 2 + \+ Item 3 + + Some math: \$\`\\sqrt{3x\-1}\+(1\+x)\^2\`\$ + + HTML entity: \  + "#; + + assert_eq!(MarkdownEscaped(input).to_string(), expected); + } + + #[test] + fn test_markdown_inline_code() { + assert_eq!(MarkdownInlineCode(" ").to_string(), "` `"); + assert_eq!(MarkdownInlineCode("text").to_string(), "`text`"); + assert_eq!(MarkdownInlineCode("text ").to_string(), "`text `"); + assert_eq!(MarkdownInlineCode(" text ").to_string(), "` text `"); + assert_eq!(MarkdownInlineCode("`").to_string(), "`` ` ``"); + assert_eq!(MarkdownInlineCode("``").to_string(), "``` `` ```"); + assert_eq!(MarkdownInlineCode("`text`").to_string(), "`` `text` ``"); + assert_eq!( + MarkdownInlineCode("some `text` no leading or trailing backticks").to_string(), + "``some `text` no leading or trailing backticks``" + ); + } + + #[test] + fn test_count_max_consecutive_chars() { + assert_eq!( + count_max_consecutive_chars("``a```b``", '`'), + 3, + "the highest seen consecutive segment of backticks counts" + ); + assert_eq!( + count_max_consecutive_chars("```a``b`", '`'), + 3, + "it can't be downgraded later" + ); + } +} diff --git a/third_party/gpui_support/gpui_util/src/paths.rs b/third_party/gpui_support/gpui_util/src/paths.rs new file mode 100644 index 0000000..59efaff --- /dev/null +++ b/third_party/gpui_support/gpui_util/src/paths.rs @@ -0,0 +1,1934 @@ +use anyhow::Context; +use globset::{Glob, GlobSet, GlobSetBuilder}; +use itertools::Itertools; +use regex::Regex; +use serde::{Deserialize, Serialize}; +use std::cmp::Ordering; +use std::error::Error; +use std::fmt::{Display, Formatter}; +use std::mem; +use std::path::StripPrefixError; +use std::sync::{Arc, OnceLock}; +use std::{ + ffi::OsStr, + path::{Path, PathBuf}, + sync::LazyLock, +}; + +use crate::rel_path::RelPath; + +static HOME_DIR: OnceLock = OnceLock::new(); + +/// Returns the path to the user's home directory. +pub fn home_dir() -> &'static PathBuf { + HOME_DIR.get_or_init(|| { + if cfg!(any(test, feature = "test-support")) { + if cfg!(target_os = "macos") { + PathBuf::from("/Users/zed") + } else if cfg!(target_os = "windows") { + PathBuf::from("C:\\Users\\zed") + } else { + PathBuf::from("/home/zed") + } + } else { + dirs::home_dir().expect("failed to determine home directory") + } + }) +} + +pub trait PathExt { + /// Compacts a given file path by replacing the user's home directory + /// prefix with a tilde (`~`). + /// + /// # Returns + /// + /// * A `PathBuf` containing the compacted file path. If the input path + /// does not have the user's home directory prefix, or if we are not on + /// Linux or macOS, the original path is returned unchanged. + fn compact(&self) -> PathBuf; + + /// Returns a file's extension or, if the file is hidden, its name without the leading dot + fn extension_or_hidden_file_name(&self) -> Option<&str>; + + fn try_from_bytes<'a>(bytes: &'a [u8]) -> anyhow::Result + where + Self: From<&'a Path>, + { + #[cfg(unix)] + { + use std::os::unix::prelude::OsStrExt; + Ok(Self::from(Path::new(OsStr::from_bytes(bytes)))) + } + #[cfg(windows)] + { + use tendril::fmt::{Format, WTF8}; + WTF8::validate(bytes) + .then(|| { + // Safety: bytes are valid WTF-8 sequence. + Self::from(Path::new(unsafe { + OsStr::from_encoded_bytes_unchecked(bytes) + })) + }) + .with_context(|| format!("Invalid WTF-8 sequence: {bytes:?}")) + } + } + + /// Converts a local path to one that can be used inside of WSL. + /// Returns `None` if the path cannot be converted into a WSL one (network share). + fn local_to_wsl(&self) -> Option; + + /// Returns a file's "full" joined collection of extensions, in the case where a file does not + /// just have a singular extension but instead has multiple (e.g File.tar.gz, Component.stories.tsx) + /// + /// Will provide back the extensions joined together such as tar.gz or stories.tsx + fn multiple_extensions(&self) -> Option; + + /// Try to make a shell-safe representation of the path. + /// + /// For Unix, the path is escaped to be safe for POSIX shells + fn try_shell_safe(&self) -> anyhow::Result; +} + +impl> PathExt for T { + fn compact(&self) -> PathBuf { + if cfg!(any(target_os = "linux", target_os = "freebsd")) || cfg!(target_os = "macos") { + match self.as_ref().strip_prefix(home_dir().as_path()) { + Ok(relative_path) => { + let mut shortened_path = PathBuf::new(); + shortened_path.push("~"); + shortened_path.push(relative_path); + shortened_path + } + Err(_) => self.as_ref().to_path_buf(), + } + } else { + self.as_ref().to_path_buf() + } + } + + fn extension_or_hidden_file_name(&self) -> Option<&str> { + let path = self.as_ref(); + let file_name = path.file_name()?.to_str()?; + if file_name.starts_with('.') { + return file_name.strip_prefix('.'); + } + + path.extension() + .and_then(|e| e.to_str()) + .or_else(|| path.file_stem()?.to_str()) + } + + fn local_to_wsl(&self) -> Option { + // quite sketchy to convert this back to path at the end, but a lot of functions only accept paths + // todo: ideally rework them..? + let mut new_path = std::ffi::OsString::new(); + for component in self.as_ref().components() { + match component { + std::path::Component::Prefix(prefix) => { + let drive_letter = prefix.as_os_str().to_string_lossy().to_lowercase(); + let drive_letter = drive_letter.strip_suffix(':')?; + + new_path.push(format!("/mnt/{}", drive_letter)); + } + std::path::Component::RootDir => {} + std::path::Component::CurDir => { + new_path.push("/."); + } + std::path::Component::ParentDir => { + new_path.push("/.."); + } + std::path::Component::Normal(os_str) => { + new_path.push("/"); + new_path.push(os_str); + } + } + } + + Some(new_path.into()) + } + + fn multiple_extensions(&self) -> Option { + let path = self.as_ref(); + let file_name = path.file_name()?.to_str()?; + + let parts: Vec<&str> = file_name + .split('.') + // Skip the part with the file name extension + .skip(1) + .collect(); + + if parts.len() < 2 { + return None; + } + + Some(parts.into_iter().join(".")) + } + + fn try_shell_safe(&self) -> anyhow::Result { + #[cfg(target_os = "windows")] + { + Ok(self.as_ref().to_string_lossy().to_string()) + } + + #[cfg(not(target_os = "windows"))] + { + let path_str = self + .as_ref() + .to_str() + .with_context(|| "Path contains invalid UTF-8")?; + + // As of writing, this can only be fail if the path contains a null byte, which shouldn't be possible + // but shlex has annotated the error as #[non_exhaustive] so we can't make it a compile error if other + // errors are introduced in the future :( + Ok(shlex::try_quote(path_str)?.into_owned()) + } + } +} + +pub fn path_ends_with(base: &Path, suffix: &Path) -> bool { + strip_path_suffix(base, suffix).is_some() +} + +pub fn strip_path_suffix<'a>(base: &'a Path, suffix: &Path) -> Option<&'a Path> { + if let Some(remainder) = base + .as_os_str() + .as_encoded_bytes() + .strip_suffix(suffix.as_os_str().as_encoded_bytes()) + { + if remainder + .last() + .is_none_or(|last_byte| std::path::is_separator(*last_byte as char)) + { + let os_str = unsafe { + OsStr::from_encoded_bytes_unchecked( + &remainder[0..remainder.len().saturating_sub(1)], + ) + }; + return Some(Path::new(os_str)); + } + } + None +} + +/// In memory, this is identical to `Path`. On non-Windows conversions to this type are no-ops. On +/// windows, these conversions sanitize UNC paths by removing the `\\\\?\\` prefix. +#[derive(Eq, PartialEq, Hash, Ord, PartialOrd)] +#[repr(transparent)] +pub struct SanitizedPath(Path); + +impl SanitizedPath { + pub fn new + ?Sized>(path: &T) -> &Self { + #[cfg(not(target_os = "windows"))] + return Self::unchecked_new(path.as_ref()); + + #[cfg(target_os = "windows")] + return Self::unchecked_new(dunce::simplified(path.as_ref())); + } + + pub fn unchecked_new + ?Sized>(path: &T) -> &Self { + // safe because `Path` and `SanitizedPath` have the same repr and Drop impl + unsafe { mem::transmute::<&Path, &Self>(path.as_ref()) } + } + + pub fn from_arc(path: Arc) -> Arc { + // safe because `Path` and `SanitizedPath` have the same repr and Drop impl + #[cfg(not(target_os = "windows"))] + return unsafe { mem::transmute::, Arc>(path) }; + + // TODO: could avoid allocating here if dunce::simplified results in the same path + #[cfg(target_os = "windows")] + return Self::new(&path).into(); + } + + pub fn new_arc + ?Sized>(path: &T) -> Arc { + Self::new(path).into() + } + + pub fn cast_arc(path: Arc) -> Arc { + // safe because `Path` and `SanitizedPath` have the same repr and Drop impl + unsafe { mem::transmute::, Arc>(path) } + } + + pub fn cast_arc_ref(path: &Arc) -> &Arc { + // safe because `Path` and `SanitizedPath` have the same repr and Drop impl + unsafe { mem::transmute::<&Arc, &Arc>(path) } + } + + pub fn starts_with(&self, prefix: &Self) -> bool { + self.0.starts_with(&prefix.0) + } + + pub fn as_path(&self) -> &Path { + &self.0 + } + + pub fn file_name(&self) -> Option<&std::ffi::OsStr> { + self.0.file_name() + } + + pub fn extension(&self) -> Option<&std::ffi::OsStr> { + self.0.extension() + } + + pub fn join>(&self, path: P) -> PathBuf { + self.0.join(path) + } + + pub fn parent(&self) -> Option<&Self> { + self.0.parent().map(Self::unchecked_new) + } + + pub fn strip_prefix(&self, base: &Self) -> Result<&Path, StripPrefixError> { + self.0.strip_prefix(base.as_path()) + } + + pub fn to_str(&self) -> Option<&str> { + self.0.to_str() + } + + pub fn to_path_buf(&self) -> PathBuf { + self.0.to_path_buf() + } +} + +impl std::fmt::Debug for SanitizedPath { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + std::fmt::Debug::fmt(&self.0, formatter) + } +} + +impl Display for SanitizedPath { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0.display()) + } +} + +impl From<&SanitizedPath> for Arc { + fn from(sanitized_path: &SanitizedPath) -> Self { + let path: Arc = sanitized_path.0.into(); + // safe because `Path` and `SanitizedPath` have the same repr and Drop impl + unsafe { mem::transmute(path) } + } +} + +impl From<&SanitizedPath> for PathBuf { + fn from(sanitized_path: &SanitizedPath) -> Self { + sanitized_path.as_path().into() + } +} + +impl AsRef for SanitizedPath { + fn as_ref(&self) -> &Path { + &self.0 + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum PathStyle { + Posix, + Windows, +} + +impl PathStyle { + #[cfg(target_os = "windows")] + pub const fn local() -> Self { + PathStyle::Windows + } + + #[cfg(not(target_os = "windows"))] + pub const fn local() -> Self { + PathStyle::Posix + } + + #[inline] + pub fn separator(&self) -> &'static str { + match self { + PathStyle::Posix => "/", + PathStyle::Windows => "\\", + } + } + + pub fn is_windows(&self) -> bool { + *self == PathStyle::Windows + } + + pub fn join(self, left: impl AsRef, right: impl AsRef) -> Option { + let right = right.as_ref().to_str()?; + if is_absolute(right, self) { + return None; + } + let left = left.as_ref().to_str()?; + if left.is_empty() { + Some(right.into()) + } else { + Some(format!( + "{left}{}{right}", + if left.ends_with(self.separator()) { + "" + } else { + self.separator() + } + )) + } + } + + pub fn split(self, path_like: &str) -> (Option<&str>, &str) { + let Some(pos) = path_like.rfind(self.separator()) else { + return (None, path_like); + }; + let filename_start = pos + self.separator().len(); + ( + Some(&path_like[..filename_start]), + &path_like[filename_start..], + ) + } +} + +#[derive(Debug, Clone)] +pub struct RemotePathBuf { + style: PathStyle, + string: String, +} + +impl RemotePathBuf { + pub fn new(string: String, style: PathStyle) -> Self { + Self { style, string } + } + + pub fn from_str(path: &str, style: PathStyle) -> Self { + Self::new(path.to_string(), style) + } + + pub fn path_style(&self) -> PathStyle { + self.style + } + + pub fn to_proto(self) -> String { + self.string + } +} + +impl Display for RemotePathBuf { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.string) + } +} + +pub fn is_absolute(path_like: &str, path_style: PathStyle) -> bool { + path_like.starts_with('/') + || path_style == PathStyle::Windows + && (path_like.starts_with('\\') + || path_like + .chars() + .next() + .is_some_and(|c| c.is_ascii_alphabetic()) + && path_like[1..] + .strip_prefix(':') + .is_some_and(|path| path.starts_with('/') || path.starts_with('\\'))) +} + +#[derive(Debug, PartialEq)] +#[non_exhaustive] +pub struct NormalizeError; + +impl Error for NormalizeError {} + +impl std::fmt::Display for NormalizeError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("parent reference `..` points outside of base directory") + } +} + +/// Copied from stdlib where it's unstable. +/// +/// Normalize a path, including `..` without traversing the filesystem. +/// +/// Returns an error if normalization would leave leading `..` components. +/// +///
+/// +/// This function always resolves `..` to the "lexical" parent. +/// That is "a/b/../c" will always resolve to `a/c` which can change the meaning of the path. +/// In particular, `a/c` and `a/b/../c` are distinct on many systems because `b` may be a symbolic link, so its parent isn't `a`. +/// +///
+/// +/// [`path::absolute`](absolute) is an alternative that preserves `..`. +/// Or [`Path::canonicalize`] can be used to resolve any `..` by querying the filesystem. +pub fn normalize_lexically(path: &Path) -> Result { + use std::path::Component; + + let mut lexical = PathBuf::new(); + let mut iter = path.components().peekable(); + + // Find the root, if any, and add it to the lexical path. + // Here we treat the Windows path "C:\" as a single "root" even though + // `components` splits it into two: (Prefix, RootDir). + let root = match iter.peek() { + Some(Component::ParentDir) => return Err(NormalizeError), + Some(p @ Component::RootDir) | Some(p @ Component::CurDir) => { + lexical.push(p); + iter.next(); + lexical.as_os_str().len() + } + Some(Component::Prefix(prefix)) => { + lexical.push(prefix.as_os_str()); + iter.next(); + if let Some(p @ Component::RootDir) = iter.peek() { + lexical.push(p); + iter.next(); + } + lexical.as_os_str().len() + } + None => return Ok(PathBuf::new()), + Some(Component::Normal(_)) => 0, + }; + + for component in iter { + match component { + Component::RootDir => unreachable!(), + Component::Prefix(_) => return Err(NormalizeError), + Component::CurDir => continue, + Component::ParentDir => { + // It's an error if ParentDir causes us to go above the "root". + if lexical.as_os_str().len() == root { + return Err(NormalizeError); + } else { + lexical.pop(); + } + } + Component::Normal(path) => lexical.push(path), + } + } + Ok(lexical) +} + +/// A delimiter to use in `path_query:row_number:column_number` strings parsing. +pub const FILE_ROW_COLUMN_DELIMITER: char = ':'; + +const ROW_COL_CAPTURE_REGEX: &str = r"(?xs) + ([^\(]+)\:(?: + \((\d+)[,:](\d+)\) # filename:(row,column), filename:(row:column) + | + \((\d+)\)() # filename:(row) + ) + | + ([^\(]+)(?: + \((\d+)[,:](\d+)\) # filename(row,column), filename(row:column) + | + \((\d+)\)() # filename(row) + ) + | + (.+?)(?: + \:+(\d+)\:(\d+)\:*$ # filename:row:column + | + \:+(\d+)\:*()$ # filename:row + | + \:+()()$ + )"; + +/// A representation of a path-like string with optional row and column numbers. +/// Matching values example: `te`, `test.rs:22`, `te:22:5`, `test.c(22)`, `test.c(22,5)`etc. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Hash)] +pub struct PathWithPosition { + pub path: PathBuf, + pub row: Option, + // Absent if row is absent. + pub column: Option, +} + +impl PathWithPosition { + /// Returns a PathWithPosition from a path. + pub fn from_path(path: PathBuf) -> Self { + Self { + path, + row: None, + column: None, + } + } + + /// Parses a string that possibly has `:row:column` or `(row, column)` suffix. + /// Parenthesis format is used by [MSBuild](https://learn.microsoft.com/en-us/visualstudio/msbuild/msbuild-diagnostic-format-for-tasks) compatible tools + /// Ignores trailing `:`s, so `test.rs:22:` is parsed as `test.rs:22`. + /// If the suffix parsing fails, the whole string is parsed as a path. + /// + /// Be mindful that `test_file:10:1:` is a valid posix filename. + /// `PathWithPosition` class assumes that the ending position-like suffix is **not** part of the filename. + /// + /// # Examples + /// + /// ``` + /// # use util::paths::PathWithPosition; + /// # use std::path::PathBuf; + /// assert_eq!(PathWithPosition::parse_str("test_file"), PathWithPosition { + /// path: PathBuf::from("test_file"), + /// row: None, + /// column: None, + /// }); + /// assert_eq!(PathWithPosition::parse_str("test_file:10"), PathWithPosition { + /// path: PathBuf::from("test_file"), + /// row: Some(10), + /// column: None, + /// }); + /// assert_eq!(PathWithPosition::parse_str("test_file.rs"), PathWithPosition { + /// path: PathBuf::from("test_file.rs"), + /// row: None, + /// column: None, + /// }); + /// assert_eq!(PathWithPosition::parse_str("test_file.rs:1"), PathWithPosition { + /// path: PathBuf::from("test_file.rs"), + /// row: Some(1), + /// column: None, + /// }); + /// assert_eq!(PathWithPosition::parse_str("test_file.rs:1:2"), PathWithPosition { + /// path: PathBuf::from("test_file.rs"), + /// row: Some(1), + /// column: Some(2), + /// }); + /// ``` + /// + /// # Expected parsing results when encounter ill-formatted inputs. + /// ``` + /// # use util::paths::PathWithPosition; + /// # use std::path::PathBuf; + /// assert_eq!(PathWithPosition::parse_str("test_file.rs:a"), PathWithPosition { + /// path: PathBuf::from("test_file.rs:a"), + /// row: None, + /// column: None, + /// }); + /// assert_eq!(PathWithPosition::parse_str("test_file.rs:a:b"), PathWithPosition { + /// path: PathBuf::from("test_file.rs:a:b"), + /// row: None, + /// column: None, + /// }); + /// assert_eq!(PathWithPosition::parse_str("test_file.rs"), PathWithPosition { + /// path: PathBuf::from("test_file.rs"), + /// row: None, + /// column: None, + /// }); + /// assert_eq!(PathWithPosition::parse_str("test_file.rs::1"), PathWithPosition { + /// path: PathBuf::from("test_file.rs"), + /// row: Some(1), + /// column: None, + /// }); + /// assert_eq!(PathWithPosition::parse_str("test_file.rs:1::"), PathWithPosition { + /// path: PathBuf::from("test_file.rs"), + /// row: Some(1), + /// column: None, + /// }); + /// assert_eq!(PathWithPosition::parse_str("test_file.rs::1:2"), PathWithPosition { + /// path: PathBuf::from("test_file.rs"), + /// row: Some(1), + /// column: Some(2), + /// }); + /// assert_eq!(PathWithPosition::parse_str("test_file.rs:1::2"), PathWithPosition { + /// path: PathBuf::from("test_file.rs:1"), + /// row: Some(2), + /// column: None, + /// }); + /// assert_eq!(PathWithPosition::parse_str("test_file.rs:1:2:3"), PathWithPosition { + /// path: PathBuf::from("test_file.rs:1"), + /// row: Some(2), + /// column: Some(3), + /// }); + /// ``` + pub fn parse_str(s: &str) -> Self { + let trimmed = s.trim(); + let path = Path::new(trimmed); + let maybe_file_name_with_row_col = path.file_name().unwrap_or_default().to_string_lossy(); + if maybe_file_name_with_row_col.is_empty() { + return Self { + path: Path::new(s).to_path_buf(), + row: None, + column: None, + }; + } + + // Let's avoid repeated init cost on this. It is subject to thread contention, but + // so far this code isn't called from multiple hot paths. Getting contention here + // in the future seems unlikely. + static SUFFIX_RE: LazyLock = + LazyLock::new(|| Regex::new(ROW_COL_CAPTURE_REGEX).unwrap()); + match SUFFIX_RE + .captures(&maybe_file_name_with_row_col) + .map(|caps| caps.extract()) + { + Some((_, [file_name, maybe_row, maybe_column])) => { + let row = maybe_row.parse::().ok(); + let column = maybe_column.parse::().ok(); + + let suffix_length = maybe_file_name_with_row_col.len() - file_name.len(); + let path_without_suffix = &trimmed[..trimmed.len() - suffix_length]; + + Self { + path: Path::new(path_without_suffix).to_path_buf(), + row, + column, + } + } + None => { + // The `ROW_COL_CAPTURE_REGEX` deals with separated digits only, + // but in reality there could be `foo/bar.py:22:in` inputs which we want to match too. + // The regex mentioned is not very extendable with "digit or random string" checks, so do this here instead. + let delimiter = ':'; + let mut path_parts = s + .rsplitn(3, delimiter) + .collect::>() + .into_iter() + .rev() + .fuse(); + let mut path_string = path_parts.next().expect("rsplitn should have the rest of the string as its last parameter that we reversed").to_owned(); + let mut row = None; + let mut column = None; + if let Some(maybe_row) = path_parts.next() { + if let Ok(parsed_row) = maybe_row.parse::() { + row = Some(parsed_row); + if let Some(parsed_column) = path_parts + .next() + .and_then(|maybe_col| maybe_col.parse::().ok()) + { + column = Some(parsed_column); + } + } else { + path_string.push(delimiter); + path_string.push_str(maybe_row); + } + } + for split in path_parts { + path_string.push(delimiter); + path_string.push_str(split); + } + + Self { + path: PathBuf::from(path_string), + row, + column, + } + } + } + } + + pub fn map_path( + self, + mapping: impl FnOnce(PathBuf) -> Result, + ) -> Result { + Ok(PathWithPosition { + path: mapping(self.path)?, + row: self.row, + column: self.column, + }) + } + + pub fn to_string(&self, path_to_string: impl Fn(&PathBuf) -> String) -> String { + let path_string = path_to_string(&self.path); + if let Some(row) = self.row { + if let Some(column) = self.column { + format!("{path_string}:{row}:{column}") + } else { + format!("{path_string}:{row}") + } + } else { + path_string + } + } +} + +#[derive(Clone, Debug)] +pub struct PathMatcher { + sources: Vec, + glob: GlobSet, + path_style: PathStyle, +} + +// impl std::fmt::Display for PathMatcher { +// fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +// self.sources.fmt(f) +// } +// } + +impl PartialEq for PathMatcher { + fn eq(&self, other: &Self) -> bool { + self.sources.eq(&other.sources) + } +} + +impl Eq for PathMatcher {} + +impl PathMatcher { + pub fn new( + globs: impl IntoIterator>, + path_style: PathStyle, + ) -> Result { + let globs = globs + .into_iter() + .map(|as_str| Glob::new(as_str.as_ref())) + .collect::, _>>()?; + let sources = globs.iter().map(|glob| glob.glob().to_owned()).collect(); + let mut glob_builder = GlobSetBuilder::new(); + for single_glob in globs { + glob_builder.add(single_glob); + } + let glob = glob_builder.build()?; + Ok(PathMatcher { + glob, + sources, + path_style, + }) + } + + pub fn sources(&self) -> &[String] { + &self.sources + } + + pub fn is_match>(&self, other: P) -> bool { + let other_path = other.as_ref(); + self.sources.iter().any(|source| { + let as_bytes = other_path.as_os_str().as_encoded_bytes(); + as_bytes.starts_with(source.as_bytes()) || as_bytes.ends_with(source.as_bytes()) + }) || self.glob.is_match(other_path) + || self.check_with_end_separator(other_path) + } + + fn check_with_end_separator(&self, path: &Path) -> bool { + let path_str = path.to_string_lossy(); + let separator = self.path_style.separator(); + if path_str.ends_with(separator) { + false + } else { + self.glob.is_match(path_str.to_string() + separator) + } + } +} + +impl Default for PathMatcher { + fn default() -> Self { + Self { + path_style: PathStyle::local(), + glob: GlobSet::empty(), + sources: vec![], + } + } +} + +/// Custom character comparison that prioritizes lowercase for same letters +fn compare_chars(a: char, b: char) -> Ordering { + // First compare case-insensitive + match a.to_ascii_lowercase().cmp(&b.to_ascii_lowercase()) { + Ordering::Equal => { + // If same letter, prioritize lowercase (lowercase < uppercase) + match (a.is_ascii_lowercase(), b.is_ascii_lowercase()) { + (true, false) => Ordering::Less, // lowercase comes first + (false, true) => Ordering::Greater, // uppercase comes after + _ => Ordering::Equal, // both same case or both non-ascii + } + } + other => other, + } +} + +/// Compares two sequences of consecutive digits for natural sorting. +/// +/// This function is a core component of natural sorting that handles numeric comparison +/// in a way that feels natural to humans. It extracts and compares consecutive digit +/// sequences from two iterators, handling various cases like leading zeros and very large numbers. +/// +/// # Behavior +/// +/// The function implements the following comparison rules: +/// 1. Different numeric values: Compares by actual numeric value (e.g., "2" < "10") +/// 2. Leading zeros: When values are equal, longer sequence wins (e.g., "002" > "2") +/// 3. Large numbers: Falls back to string comparison for numbers that would overflow u128 +/// +/// # Examples +/// +/// ```text +/// "1" vs "2" -> Less (different values) +/// "2" vs "10" -> Less (numeric comparison) +/// "002" vs "2" -> Greater (leading zeros) +/// "10" vs "010" -> Less (leading zeros) +/// "999..." vs "1000..." -> Less (large number comparison) +/// ``` +/// +/// # Implementation Details +/// +/// 1. Extracts consecutive digits into strings +/// 2. Compares sequence lengths for leading zero handling +/// 3. For equal lengths, compares digit by digit +/// 4. For different lengths: +/// - Attempts numeric comparison first (for numbers up to 2^128 - 1) +/// - Falls back to string comparison if numbers would overflow +/// +/// The function advances both iterators past their respective numeric sequences, +/// regardless of the comparison result. +fn compare_numeric_segments( + a_iter: &mut std::iter::Peekable, + b_iter: &mut std::iter::Peekable, +) -> Ordering +where + I: Iterator, +{ + // Collect all consecutive digits into strings + let mut a_num_str = String::new(); + let mut b_num_str = String::new(); + + while let Some(&c) = a_iter.peek() { + if !c.is_ascii_digit() { + break; + } + + a_num_str.push(c); + a_iter.next(); + } + + while let Some(&c) = b_iter.peek() { + if !c.is_ascii_digit() { + break; + } + + b_num_str.push(c); + b_iter.next(); + } + + // First compare lengths (handle leading zeros) + match a_num_str.len().cmp(&b_num_str.len()) { + Ordering::Equal => { + // Same length, compare digit by digit + match a_num_str.cmp(&b_num_str) { + Ordering::Equal => Ordering::Equal, + ordering => ordering, + } + } + + // Different lengths but same value means leading zeros + ordering => { + // Try parsing as numbers first + if let (Ok(a_val), Ok(b_val)) = (a_num_str.parse::(), b_num_str.parse::()) { + match a_val.cmp(&b_val) { + Ordering::Equal => ordering, // Same value, longer one is greater (leading zeros) + ord => ord, + } + } else { + // If parsing fails (overflow), compare as strings + a_num_str.cmp(&b_num_str) + } + } + } +} + +/// Performs natural sorting comparison between two strings. +/// +/// Natural sorting is an ordering that handles numeric sequences in a way that matches human expectations. +/// For example, "file2" comes before "file10" (unlike standard lexicographic sorting). +/// +/// # Characteristics +/// +/// * Case-sensitive with lowercase priority: When comparing same letters, lowercase comes before uppercase +/// * Numbers are compared by numeric value, not character by character +/// * Leading zeros affect ordering when numeric values are equal +/// * Can handle numbers larger than u128::MAX (falls back to string comparison) +/// +/// # Algorithm +/// +/// The function works by: +/// 1. Processing strings character by character +/// 2. When encountering digits, treating consecutive digits as a single number +/// 3. Comparing numbers by their numeric value rather than lexicographically +/// 4. For non-numeric characters, using case-sensitive comparison with lowercase priority +fn natural_sort(a: &str, b: &str) -> Ordering { + let mut a_iter = a.chars().peekable(); + let mut b_iter = b.chars().peekable(); + + loop { + match (a_iter.peek(), b_iter.peek()) { + (None, None) => return Ordering::Equal, + (None, _) => return Ordering::Less, + (_, None) => return Ordering::Greater, + (Some(&a_char), Some(&b_char)) => { + if a_char.is_ascii_digit() && b_char.is_ascii_digit() { + match compare_numeric_segments(&mut a_iter, &mut b_iter) { + Ordering::Equal => continue, + ordering => return ordering, + } + } else { + match compare_chars(a_char, b_char) { + Ordering::Equal => { + a_iter.next(); + b_iter.next(); + } + ordering => return ordering, + } + } + } + } + } +} +pub fn compare_rel_paths( + (path_a, a_is_file): (&RelPath, bool), + (path_b, b_is_file): (&RelPath, bool), +) -> Ordering { + let mut components_a = path_a.components(); + let mut components_b = path_b.components(); + + fn stem_and_extension(filename: &str) -> (Option<&str>, Option<&str>) { + if filename.is_empty() { + return (None, None); + } + + match filename.rsplit_once('.') { + // Case 1: No dot was found. The entire name is the stem. + None => (Some(filename), None), + + // Case 2: A dot was found. + Some((before, after)) => { + // This is the crucial check for dotfiles like ".bashrc". + // If `before` is empty, the dot was the first character. + // In that case, we revert to the "whole name is the stem" logic. + if before.is_empty() { + (Some(filename), None) + } else { + // Otherwise, we have a standard stem and extension. + (Some(before), Some(after)) + } + } + } + } + loop { + match (components_a.next(), components_b.next()) { + (Some(component_a), Some(component_b)) => { + let a_is_file = a_is_file && components_a.rest().is_empty(); + let b_is_file = b_is_file && components_b.rest().is_empty(); + + let ordering = a_is_file.cmp(&b_is_file).then_with(|| { + let (a_stem, a_extension) = a_is_file + .then(|| stem_and_extension(component_a)) + .unwrap_or_default(); + let path_string_a = if a_is_file { a_stem } else { Some(component_a) }; + + let (b_stem, b_extension) = b_is_file + .then(|| stem_and_extension(component_b)) + .unwrap_or_default(); + let path_string_b = if b_is_file { b_stem } else { Some(component_b) }; + + let compare_components = match (path_string_a, path_string_b) { + (Some(a), Some(b)) => natural_sort(&a, &b), + (Some(_), None) => Ordering::Greater, + (None, Some(_)) => Ordering::Less, + (None, None) => Ordering::Equal, + }; + + compare_components.then_with(|| { + if a_is_file && b_is_file { + let ext_a = a_extension.unwrap_or_default(); + let ext_b = b_extension.unwrap_or_default(); + ext_a.cmp(ext_b) + } else { + Ordering::Equal + } + }) + }); + + if !ordering.is_eq() { + return ordering; + } + } + (Some(_), None) => break Ordering::Greater, + (None, Some(_)) => break Ordering::Less, + (None, None) => break Ordering::Equal, + } + } +} + +pub fn compare_paths( + (path_a, a_is_file): (&Path, bool), + (path_b, b_is_file): (&Path, bool), +) -> Ordering { + let mut components_a = path_a.components().peekable(); + let mut components_b = path_b.components().peekable(); + + loop { + match (components_a.next(), components_b.next()) { + (Some(component_a), Some(component_b)) => { + let a_is_file = components_a.peek().is_none() && a_is_file; + let b_is_file = components_b.peek().is_none() && b_is_file; + + let ordering = a_is_file.cmp(&b_is_file).then_with(|| { + let path_a = Path::new(component_a.as_os_str()); + let path_string_a = if a_is_file { + path_a.file_stem() + } else { + path_a.file_name() + } + .map(|s| s.to_string_lossy()); + + let path_b = Path::new(component_b.as_os_str()); + let path_string_b = if b_is_file { + path_b.file_stem() + } else { + path_b.file_name() + } + .map(|s| s.to_string_lossy()); + + let compare_components = match (path_string_a, path_string_b) { + (Some(a), Some(b)) => natural_sort(&a, &b), + (Some(_), None) => Ordering::Greater, + (None, Some(_)) => Ordering::Less, + (None, None) => Ordering::Equal, + }; + + compare_components.then_with(|| { + if a_is_file && b_is_file { + let ext_a = path_a.extension().unwrap_or_default(); + let ext_b = path_b.extension().unwrap_or_default(); + ext_a.cmp(ext_b) + } else { + Ordering::Equal + } + }) + }); + + if !ordering.is_eq() { + return ordering; + } + } + (Some(_), None) => break Ordering::Greater, + (None, Some(_)) => break Ordering::Less, + (None, None) => break Ordering::Equal, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use util_macros::perf; + + #[perf] + fn compare_paths_with_dots() { + let mut paths = vec![ + (Path::new("test_dirs"), false), + (Path::new("test_dirs/1.46"), false), + (Path::new("test_dirs/1.46/bar_1"), true), + (Path::new("test_dirs/1.46/bar_2"), true), + (Path::new("test_dirs/1.45"), false), + (Path::new("test_dirs/1.45/foo_2"), true), + (Path::new("test_dirs/1.45/foo_1"), true), + ]; + paths.sort_by(|&a, &b| compare_paths(a, b)); + assert_eq!( + paths, + vec![ + (Path::new("test_dirs"), false), + (Path::new("test_dirs/1.45"), false), + (Path::new("test_dirs/1.45/foo_1"), true), + (Path::new("test_dirs/1.45/foo_2"), true), + (Path::new("test_dirs/1.46"), false), + (Path::new("test_dirs/1.46/bar_1"), true), + (Path::new("test_dirs/1.46/bar_2"), true), + ] + ); + let mut paths = vec![ + (Path::new("root1/one.txt"), true), + (Path::new("root1/one.two.txt"), true), + ]; + paths.sort_by(|&a, &b| compare_paths(a, b)); + assert_eq!( + paths, + vec![ + (Path::new("root1/one.txt"), true), + (Path::new("root1/one.two.txt"), true), + ] + ); + } + + #[perf] + fn compare_paths_with_same_name_different_extensions() { + let mut paths = vec![ + (Path::new("test_dirs/file.rs"), true), + (Path::new("test_dirs/file.txt"), true), + (Path::new("test_dirs/file.md"), true), + (Path::new("test_dirs/file"), true), + (Path::new("test_dirs/file.a"), true), + ]; + paths.sort_by(|&a, &b| compare_paths(a, b)); + assert_eq!( + paths, + vec![ + (Path::new("test_dirs/file"), true), + (Path::new("test_dirs/file.a"), true), + (Path::new("test_dirs/file.md"), true), + (Path::new("test_dirs/file.rs"), true), + (Path::new("test_dirs/file.txt"), true), + ] + ); + } + + #[perf] + fn compare_paths_case_semi_sensitive() { + let mut paths = vec![ + (Path::new("test_DIRS"), false), + (Path::new("test_DIRS/foo_1"), true), + (Path::new("test_DIRS/foo_2"), true), + (Path::new("test_DIRS/bar"), true), + (Path::new("test_DIRS/BAR"), true), + (Path::new("test_dirs"), false), + (Path::new("test_dirs/foo_1"), true), + (Path::new("test_dirs/foo_2"), true), + (Path::new("test_dirs/bar"), true), + (Path::new("test_dirs/BAR"), true), + ]; + paths.sort_by(|&a, &b| compare_paths(a, b)); + assert_eq!( + paths, + vec![ + (Path::new("test_dirs"), false), + (Path::new("test_dirs/bar"), true), + (Path::new("test_dirs/BAR"), true), + (Path::new("test_dirs/foo_1"), true), + (Path::new("test_dirs/foo_2"), true), + (Path::new("test_DIRS"), false), + (Path::new("test_DIRS/bar"), true), + (Path::new("test_DIRS/BAR"), true), + (Path::new("test_DIRS/foo_1"), true), + (Path::new("test_DIRS/foo_2"), true), + ] + ); + } + + #[perf] + fn path_with_position_parse_posix_path() { + // Test POSIX filename edge cases + // Read more at https://en.wikipedia.org/wiki/Filename + assert_eq!( + PathWithPosition::parse_str("test_file"), + PathWithPosition { + path: PathBuf::from("test_file"), + row: None, + column: None + } + ); + + assert_eq!( + PathWithPosition::parse_str("a:bc:.zip:1"), + PathWithPosition { + path: PathBuf::from("a:bc:.zip"), + row: Some(1), + column: None + } + ); + + assert_eq!( + PathWithPosition::parse_str("one.second.zip:1"), + PathWithPosition { + path: PathBuf::from("one.second.zip"), + row: Some(1), + column: None + } + ); + + // Trim off trailing `:`s for otherwise valid input. + assert_eq!( + PathWithPosition::parse_str("test_file:10:1:"), + PathWithPosition { + path: PathBuf::from("test_file"), + row: Some(10), + column: Some(1) + } + ); + + assert_eq!( + PathWithPosition::parse_str("test_file.rs:"), + PathWithPosition { + path: PathBuf::from("test_file.rs"), + row: None, + column: None + } + ); + + assert_eq!( + PathWithPosition::parse_str("test_file.rs:1:"), + PathWithPosition { + path: PathBuf::from("test_file.rs"), + row: Some(1), + column: None + } + ); + + assert_eq!( + PathWithPosition::parse_str("ab\ncd"), + PathWithPosition { + path: PathBuf::from("ab\ncd"), + row: None, + column: None + } + ); + + assert_eq!( + PathWithPosition::parse_str("👋\nab"), + PathWithPosition { + path: PathBuf::from("👋\nab"), + row: None, + column: None + } + ); + + assert_eq!( + PathWithPosition::parse_str("Types.hs:(617,9)-(670,28):"), + PathWithPosition { + path: PathBuf::from("Types.hs"), + row: Some(617), + column: Some(9), + } + ); + } + + #[perf] + #[cfg(not(target_os = "windows"))] + fn path_with_position_parse_posix_path_with_suffix() { + assert_eq!( + PathWithPosition::parse_str("foo/bar:34:in"), + PathWithPosition { + path: PathBuf::from("foo/bar"), + row: Some(34), + column: None, + } + ); + assert_eq!( + PathWithPosition::parse_str("foo/bar.rs:1902:::15:"), + PathWithPosition { + path: PathBuf::from("foo/bar.rs:1902"), + row: Some(15), + column: None + } + ); + + assert_eq!( + PathWithPosition::parse_str("app-editors:zed-0.143.6:20240710-201212.log:34:"), + PathWithPosition { + path: PathBuf::from("app-editors:zed-0.143.6:20240710-201212.log"), + row: Some(34), + column: None, + } + ); + + assert_eq!( + PathWithPosition::parse_str("crates/file_finder/src/file_finder.rs:1902:13:"), + PathWithPosition { + path: PathBuf::from("crates/file_finder/src/file_finder.rs"), + row: Some(1902), + column: Some(13), + } + ); + + assert_eq!( + PathWithPosition::parse_str("crate/utils/src/test:today.log:34"), + PathWithPosition { + path: PathBuf::from("crate/utils/src/test:today.log"), + row: Some(34), + column: None, + } + ); + assert_eq!( + PathWithPosition::parse_str("/testing/out/src/file_finder.odin(7:15)"), + PathWithPosition { + path: PathBuf::from("/testing/out/src/file_finder.odin"), + row: Some(7), + column: Some(15), + } + ); + } + + #[perf] + #[cfg(target_os = "windows")] + fn path_with_position_parse_windows_path() { + assert_eq!( + PathWithPosition::parse_str("crates\\utils\\paths.rs"), + PathWithPosition { + path: PathBuf::from("crates\\utils\\paths.rs"), + row: None, + column: None + } + ); + + assert_eq!( + PathWithPosition::parse_str("C:\\Users\\someone\\test_file.rs"), + PathWithPosition { + path: PathBuf::from("C:\\Users\\someone\\test_file.rs"), + row: None, + column: None + } + ); + } + + #[perf] + #[cfg(target_os = "windows")] + fn path_with_position_parse_windows_path_with_suffix() { + assert_eq!( + PathWithPosition::parse_str("crates\\utils\\paths.rs:101"), + PathWithPosition { + path: PathBuf::from("crates\\utils\\paths.rs"), + row: Some(101), + column: None + } + ); + + assert_eq!( + PathWithPosition::parse_str("\\\\?\\C:\\Users\\someone\\test_file.rs:1:20"), + PathWithPosition { + path: PathBuf::from("\\\\?\\C:\\Users\\someone\\test_file.rs"), + row: Some(1), + column: Some(20) + } + ); + + assert_eq!( + PathWithPosition::parse_str("C:\\Users\\someone\\test_file.rs(1902,13)"), + PathWithPosition { + path: PathBuf::from("C:\\Users\\someone\\test_file.rs"), + row: Some(1902), + column: Some(13) + } + ); + + // Trim off trailing `:`s for otherwise valid input. + assert_eq!( + PathWithPosition::parse_str("\\\\?\\C:\\Users\\someone\\test_file.rs:1902:13:"), + PathWithPosition { + path: PathBuf::from("\\\\?\\C:\\Users\\someone\\test_file.rs"), + row: Some(1902), + column: Some(13) + } + ); + + assert_eq!( + PathWithPosition::parse_str("\\\\?\\C:\\Users\\someone\\test_file.rs:1902:13:15:"), + PathWithPosition { + path: PathBuf::from("\\\\?\\C:\\Users\\someone\\test_file.rs:1902"), + row: Some(13), + column: Some(15) + } + ); + + assert_eq!( + PathWithPosition::parse_str("\\\\?\\C:\\Users\\someone\\test_file.rs:1902:::15:"), + PathWithPosition { + path: PathBuf::from("\\\\?\\C:\\Users\\someone\\test_file.rs:1902"), + row: Some(15), + column: None + } + ); + + assert_eq!( + PathWithPosition::parse_str("\\\\?\\C:\\Users\\someone\\test_file.rs(1902,13):"), + PathWithPosition { + path: PathBuf::from("\\\\?\\C:\\Users\\someone\\test_file.rs"), + row: Some(1902), + column: Some(13), + } + ); + + assert_eq!( + PathWithPosition::parse_str("\\\\?\\C:\\Users\\someone\\test_file.rs(1902):"), + PathWithPosition { + path: PathBuf::from("\\\\?\\C:\\Users\\someone\\test_file.rs"), + row: Some(1902), + column: None, + } + ); + + assert_eq!( + PathWithPosition::parse_str("C:\\Users\\someone\\test_file.rs:1902:13:"), + PathWithPosition { + path: PathBuf::from("C:\\Users\\someone\\test_file.rs"), + row: Some(1902), + column: Some(13), + } + ); + + assert_eq!( + PathWithPosition::parse_str("C:\\Users\\someone\\test_file.rs(1902,13):"), + PathWithPosition { + path: PathBuf::from("C:\\Users\\someone\\test_file.rs"), + row: Some(1902), + column: Some(13), + } + ); + + assert_eq!( + PathWithPosition::parse_str("C:\\Users\\someone\\test_file.rs(1902):"), + PathWithPosition { + path: PathBuf::from("C:\\Users\\someone\\test_file.rs"), + row: Some(1902), + column: None, + } + ); + + assert_eq!( + PathWithPosition::parse_str("crates/utils/paths.rs:101"), + PathWithPosition { + path: PathBuf::from("crates\\utils\\paths.rs"), + row: Some(101), + column: None, + } + ); + } + + #[perf] + fn test_path_compact() { + let path: PathBuf = [ + home_dir().to_string_lossy().into_owned(), + "some_file.txt".to_string(), + ] + .iter() + .collect(); + if cfg!(any(target_os = "linux", target_os = "freebsd")) || cfg!(target_os = "macos") { + assert_eq!(path.compact().to_str(), Some("~/some_file.txt")); + } else { + assert_eq!(path.compact().to_str(), path.to_str()); + } + } + + #[perf] + fn test_extension_or_hidden_file_name() { + // No dots in name + let path = Path::new("/a/b/c/file_name.rs"); + assert_eq!(path.extension_or_hidden_file_name(), Some("rs")); + + // Single dot in name + let path = Path::new("/a/b/c/file.name.rs"); + assert_eq!(path.extension_or_hidden_file_name(), Some("rs")); + + // Multiple dots in name + let path = Path::new("/a/b/c/long.file.name.rs"); + assert_eq!(path.extension_or_hidden_file_name(), Some("rs")); + + // Hidden file, no extension + let path = Path::new("/a/b/c/.gitignore"); + assert_eq!(path.extension_or_hidden_file_name(), Some("gitignore")); + + // Hidden file, with extension + let path = Path::new("/a/b/c/.eslintrc.js"); + assert_eq!(path.extension_or_hidden_file_name(), Some("eslintrc.js")); + } + + #[perf] + fn edge_of_glob() { + let path = Path::new("/work/node_modules"); + let path_matcher = + PathMatcher::new(&["**/node_modules/**".to_owned()], PathStyle::Posix).unwrap(); + assert!( + path_matcher.is_match(path), + "Path matcher should match {path:?}" + ); + } + + #[perf] + fn project_search() { + let path = Path::new("/Users/someonetoignore/work/zed/zed.dev/node_modules"); + let path_matcher = + PathMatcher::new(&["**/node_modules/**".to_owned()], PathStyle::Posix).unwrap(); + assert!( + path_matcher.is_match(path), + "Path matcher should match {path:?}" + ); + } + + #[perf] + #[cfg(target_os = "windows")] + fn test_sanitized_path() { + let path = Path::new("C:\\Users\\someone\\test_file.rs"); + let sanitized_path = SanitizedPath::new(path); + assert_eq!( + sanitized_path.to_string(), + "C:\\Users\\someone\\test_file.rs" + ); + + let path = Path::new("\\\\?\\C:\\Users\\someone\\test_file.rs"); + let sanitized_path = SanitizedPath::new(path); + assert_eq!( + sanitized_path.to_string(), + "C:\\Users\\someone\\test_file.rs" + ); + } + + #[perf] + fn test_compare_numeric_segments() { + // Helper function to create peekable iterators and test + fn compare(a: &str, b: &str) -> Ordering { + let mut a_iter = a.chars().peekable(); + let mut b_iter = b.chars().peekable(); + + let result = compare_numeric_segments(&mut a_iter, &mut b_iter); + + // Verify iterators advanced correctly + assert!( + !a_iter.next().is_some_and(|c| c.is_ascii_digit()), + "Iterator a should have consumed all digits" + ); + assert!( + !b_iter.next().is_some_and(|c| c.is_ascii_digit()), + "Iterator b should have consumed all digits" + ); + + result + } + + // Basic numeric comparisons + assert_eq!(compare("0", "0"), Ordering::Equal); + assert_eq!(compare("1", "2"), Ordering::Less); + assert_eq!(compare("9", "10"), Ordering::Less); + assert_eq!(compare("10", "9"), Ordering::Greater); + assert_eq!(compare("99", "100"), Ordering::Less); + + // Leading zeros + assert_eq!(compare("0", "00"), Ordering::Less); + assert_eq!(compare("00", "0"), Ordering::Greater); + assert_eq!(compare("01", "1"), Ordering::Greater); + assert_eq!(compare("001", "1"), Ordering::Greater); + assert_eq!(compare("001", "01"), Ordering::Greater); + + // Same value different representation + assert_eq!(compare("000100", "100"), Ordering::Greater); + assert_eq!(compare("100", "0100"), Ordering::Less); + assert_eq!(compare("0100", "00100"), Ordering::Less); + + // Large numbers + assert_eq!(compare("9999999999", "10000000000"), Ordering::Less); + assert_eq!( + compare( + "340282366920938463463374607431768211455", // u128::MAX + "340282366920938463463374607431768211456" + ), + Ordering::Less + ); + assert_eq!( + compare( + "340282366920938463463374607431768211456", // > u128::MAX + "340282366920938463463374607431768211455" + ), + Ordering::Greater + ); + + // Iterator advancement verification + let mut a_iter = "123abc".chars().peekable(); + let mut b_iter = "456def".chars().peekable(); + + compare_numeric_segments(&mut a_iter, &mut b_iter); + + assert_eq!(a_iter.collect::(), "abc"); + assert_eq!(b_iter.collect::(), "def"); + } + + #[perf] + fn test_natural_sort() { + // Basic alphanumeric + assert_eq!(natural_sort("a", "b"), Ordering::Less); + assert_eq!(natural_sort("b", "a"), Ordering::Greater); + assert_eq!(natural_sort("a", "a"), Ordering::Equal); + + // Case sensitivity + assert_eq!(natural_sort("a", "A"), Ordering::Less); + assert_eq!(natural_sort("A", "a"), Ordering::Greater); + assert_eq!(natural_sort("aA", "aa"), Ordering::Greater); + assert_eq!(natural_sort("aa", "aA"), Ordering::Less); + + // Numbers + assert_eq!(natural_sort("1", "2"), Ordering::Less); + assert_eq!(natural_sort("2", "10"), Ordering::Less); + assert_eq!(natural_sort("02", "10"), Ordering::Less); + assert_eq!(natural_sort("02", "2"), Ordering::Greater); + + // Mixed alphanumeric + assert_eq!(natural_sort("a1", "a2"), Ordering::Less); + assert_eq!(natural_sort("a2", "a10"), Ordering::Less); + assert_eq!(natural_sort("a02", "a2"), Ordering::Greater); + assert_eq!(natural_sort("a1b", "a1c"), Ordering::Less); + + // Multiple numeric segments + assert_eq!(natural_sort("1a2", "1a10"), Ordering::Less); + assert_eq!(natural_sort("1a10", "1a2"), Ordering::Greater); + assert_eq!(natural_sort("2a1", "10a1"), Ordering::Less); + + // Special characters + assert_eq!(natural_sort("a-1", "a-2"), Ordering::Less); + assert_eq!(natural_sort("a_1", "a_2"), Ordering::Less); + assert_eq!(natural_sort("a.1", "a.2"), Ordering::Less); + + // Unicode + assert_eq!(natural_sort("文1", "文2"), Ordering::Less); + assert_eq!(natural_sort("文2", "文10"), Ordering::Less); + assert_eq!(natural_sort("🔤1", "🔤2"), Ordering::Less); + + // Empty and special cases + assert_eq!(natural_sort("", ""), Ordering::Equal); + assert_eq!(natural_sort("", "a"), Ordering::Less); + assert_eq!(natural_sort("a", ""), Ordering::Greater); + assert_eq!(natural_sort(" ", " "), Ordering::Less); + + // Mixed everything + assert_eq!(natural_sort("File-1.txt", "File-2.txt"), Ordering::Less); + assert_eq!(natural_sort("File-02.txt", "File-2.txt"), Ordering::Greater); + assert_eq!(natural_sort("File-2.txt", "File-10.txt"), Ordering::Less); + assert_eq!(natural_sort("File_A1", "File_A2"), Ordering::Less); + assert_eq!(natural_sort("File_a1", "File_A1"), Ordering::Less); + } + + #[perf] + fn test_compare_paths() { + // Helper function for cleaner tests + fn compare(a: &str, is_a_file: bool, b: &str, is_b_file: bool) -> Ordering { + compare_paths((Path::new(a), is_a_file), (Path::new(b), is_b_file)) + } + + // Basic path comparison + assert_eq!(compare("a", true, "b", true), Ordering::Less); + assert_eq!(compare("b", true, "a", true), Ordering::Greater); + assert_eq!(compare("a", true, "a", true), Ordering::Equal); + + // Files vs Directories + assert_eq!(compare("a", true, "a", false), Ordering::Greater); + assert_eq!(compare("a", false, "a", true), Ordering::Less); + assert_eq!(compare("b", false, "a", true), Ordering::Less); + + // Extensions + assert_eq!(compare("a.txt", true, "a.md", true), Ordering::Greater); + assert_eq!(compare("a.md", true, "a.txt", true), Ordering::Less); + assert_eq!(compare("a", true, "a.txt", true), Ordering::Less); + + // Nested paths + assert_eq!(compare("dir/a", true, "dir/b", true), Ordering::Less); + assert_eq!(compare("dir1/a", true, "dir2/a", true), Ordering::Less); + assert_eq!(compare("dir/sub/a", true, "dir/a", true), Ordering::Less); + + // Case sensitivity in paths + assert_eq!( + compare("Dir/file", true, "dir/file", true), + Ordering::Greater + ); + assert_eq!( + compare("dir/File", true, "dir/file", true), + Ordering::Greater + ); + assert_eq!(compare("dir/file", true, "Dir/File", true), Ordering::Less); + + // Hidden files and special names + assert_eq!(compare(".hidden", true, "visible", true), Ordering::Less); + assert_eq!(compare("_special", true, "normal", true), Ordering::Less); + assert_eq!(compare(".config", false, ".data", false), Ordering::Less); + + // Mixed numeric paths + assert_eq!( + compare("dir1/file", true, "dir2/file", true), + Ordering::Less + ); + assert_eq!( + compare("dir2/file", true, "dir10/file", true), + Ordering::Less + ); + assert_eq!( + compare("dir02/file", true, "dir2/file", true), + Ordering::Greater + ); + + // Root paths + assert_eq!(compare("/a", true, "/b", true), Ordering::Less); + assert_eq!(compare("/", false, "/a", true), Ordering::Less); + + // Complex real-world examples + assert_eq!( + compare("project/src/main.rs", true, "project/src/lib.rs", true), + Ordering::Greater + ); + assert_eq!( + compare( + "project/tests/test_1.rs", + true, + "project/tests/test_2.rs", + true + ), + Ordering::Less + ); + assert_eq!( + compare( + "project/v1.0.0/README.md", + true, + "project/v1.10.0/README.md", + true + ), + Ordering::Less + ); + } + + #[perf] + fn test_natural_sort_case_sensitivity() { + std::thread::sleep(std::time::Duration::from_millis(100)); + // Same letter different case - lowercase should come first + assert_eq!(natural_sort("a", "A"), Ordering::Less); + assert_eq!(natural_sort("A", "a"), Ordering::Greater); + assert_eq!(natural_sort("a", "a"), Ordering::Equal); + assert_eq!(natural_sort("A", "A"), Ordering::Equal); + + // Mixed case strings + assert_eq!(natural_sort("aaa", "AAA"), Ordering::Less); + assert_eq!(natural_sort("AAA", "aaa"), Ordering::Greater); + assert_eq!(natural_sort("aAa", "AaA"), Ordering::Less); + + // Different letters + assert_eq!(natural_sort("a", "b"), Ordering::Less); + assert_eq!(natural_sort("A", "b"), Ordering::Less); + assert_eq!(natural_sort("a", "B"), Ordering::Less); + } + + #[perf] + fn test_natural_sort_with_numbers() { + // Basic number ordering + assert_eq!(natural_sort("file1", "file2"), Ordering::Less); + assert_eq!(natural_sort("file2", "file10"), Ordering::Less); + assert_eq!(natural_sort("file10", "file2"), Ordering::Greater); + + // Numbers in different positions + assert_eq!(natural_sort("1file", "2file"), Ordering::Less); + assert_eq!(natural_sort("file1text", "file2text"), Ordering::Less); + assert_eq!(natural_sort("text1file", "text2file"), Ordering::Less); + + // Multiple numbers in string + assert_eq!(natural_sort("file1-2", "file1-10"), Ordering::Less); + assert_eq!(natural_sort("2-1file", "10-1file"), Ordering::Less); + + // Leading zeros + assert_eq!(natural_sort("file002", "file2"), Ordering::Greater); + assert_eq!(natural_sort("file002", "file10"), Ordering::Less); + + // Very large numbers + assert_eq!( + natural_sort("file999999999999999999999", "file999999999999999999998"), + Ordering::Greater + ); + + // u128 edge cases + + // Numbers near u128::MAX (340,282,366,920,938,463,463,374,607,431,768,211,455) + assert_eq!( + natural_sort( + "file340282366920938463463374607431768211454", + "file340282366920938463463374607431768211455" + ), + Ordering::Less + ); + + // Equal length numbers that overflow u128 + assert_eq!( + natural_sort( + "file340282366920938463463374607431768211456", + "file340282366920938463463374607431768211455" + ), + Ordering::Greater + ); + + // Different length numbers that overflow u128 + assert_eq!( + natural_sort( + "file3402823669209384634633746074317682114560", + "file340282366920938463463374607431768211455" + ), + Ordering::Greater + ); + + // Leading zeros with numbers near u128::MAX + assert_eq!( + natural_sort( + "file0340282366920938463463374607431768211455", + "file340282366920938463463374607431768211455" + ), + Ordering::Greater + ); + + // Very large numbers with different lengths (both overflow u128) + assert_eq!( + natural_sort( + "file999999999999999999999999999999999999999999999999", + "file9999999999999999999999999999999999999999999999999" + ), + Ordering::Less + ); + + // Mixed case with numbers + assert_eq!(natural_sort("File1", "file2"), Ordering::Greater); + assert_eq!(natural_sort("file1", "File2"), Ordering::Less); + } + + #[perf] + fn test_natural_sort_edge_cases() { + // Empty strings + assert_eq!(natural_sort("", ""), Ordering::Equal); + assert_eq!(natural_sort("", "a"), Ordering::Less); + assert_eq!(natural_sort("a", ""), Ordering::Greater); + + // Special characters + assert_eq!(natural_sort("file-1", "file_1"), Ordering::Less); + assert_eq!(natural_sort("file.1", "file_1"), Ordering::Less); + assert_eq!(natural_sort("file 1", "file_1"), Ordering::Less); + + // Unicode characters + // 9312 vs 9313 + assert_eq!(natural_sort("file①", "file②"), Ordering::Less); + // 9321 vs 9313 + assert_eq!(natural_sort("file⑩", "file②"), Ordering::Greater); + // 28450 vs 23383 + assert_eq!(natural_sort("file漢", "file字"), Ordering::Greater); + + // Mixed alphanumeric with special chars + assert_eq!(natural_sort("file-1a", "file-1b"), Ordering::Less); + assert_eq!(natural_sort("file-1.2", "file-1.10"), Ordering::Less); + assert_eq!(natural_sort("file-1.10", "file-1.2"), Ordering::Greater); + } + + #[test] + fn test_multiple_extensions() { + // No extensions + let path = Path::new("/a/b/c/file_name"); + assert_eq!(path.multiple_extensions(), None); + + // Only one extension + let path = Path::new("/a/b/c/file_name.tsx"); + assert_eq!(path.multiple_extensions(), None); + + // Stories sample extension + let path = Path::new("/a/b/c/file_name.stories.tsx"); + assert_eq!(path.multiple_extensions(), Some("stories.tsx".to_string())); + + // Longer sample extension + let path = Path::new("/a/b/c/long.app.tar.gz"); + assert_eq!(path.multiple_extensions(), Some("app.tar.gz".to_string())); + } + + #[test] + fn test_strip_path_suffix() { + let base = Path::new("/a/b/c/file_name"); + let suffix = Path::new("file_name"); + assert_eq!(strip_path_suffix(base, suffix), Some(Path::new("/a/b/c"))); + + let base = Path::new("/a/b/c/file_name.tsx"); + let suffix = Path::new("file_name.tsx"); + assert_eq!(strip_path_suffix(base, suffix), Some(Path::new("/a/b/c"))); + + let base = Path::new("/a/b/c/file_name.stories.tsx"); + let suffix = Path::new("c/file_name.stories.tsx"); + assert_eq!(strip_path_suffix(base, suffix), Some(Path::new("/a/b"))); + + let base = Path::new("/a/b/c/long.app.tar.gz"); + let suffix = Path::new("b/c/long.app.tar.gz"); + assert_eq!(strip_path_suffix(base, suffix), Some(Path::new("/a"))); + + let base = Path::new("/a/b/c/long.app.tar.gz"); + let suffix = Path::new("/a/b/c/long.app.tar.gz"); + assert_eq!(strip_path_suffix(base, suffix), Some(Path::new(""))); + + let base = Path::new("/a/b/c/long.app.tar.gz"); + let suffix = Path::new("/a/b/c/no_match.app.tar.gz"); + assert_eq!(strip_path_suffix(base, suffix), None); + + let base = Path::new("/a/b/c/long.app.tar.gz"); + let suffix = Path::new("app.tar.gz"); + assert_eq!(strip_path_suffix(base, suffix), None); + } +} diff --git a/third_party/gpui_support/gpui_util/src/redact.rs b/third_party/gpui_support/gpui_util/src/redact.rs new file mode 100644 index 0000000..6b297df --- /dev/null +++ b/third_party/gpui_support/gpui_util/src/redact.rs @@ -0,0 +1,15 @@ +/// Whether a given environment variable name should have its value redacted +pub fn should_redact(env_var_name: &str) -> bool { + const REDACTED_SUFFIXES: &[&str] = &[ + "KEY", + "TOKEN", + "PASSWORD", + "SECRET", + "PASS", + "CREDENTIALS", + "LICENSE", + ]; + REDACTED_SUFFIXES + .iter() + .any(|suffix| env_var_name.ends_with(suffix)) +} diff --git a/third_party/gpui_support/gpui_util/src/rel_path.rs b/third_party/gpui_support/gpui_util/src/rel_path.rs new file mode 100644 index 0000000..b360297 --- /dev/null +++ b/third_party/gpui_support/gpui_util/src/rel_path.rs @@ -0,0 +1,579 @@ +use crate::paths::{PathStyle, is_absolute}; +use anyhow::{Context as _, Result, anyhow}; +use serde::{Deserialize, Serialize}; +use std::{ + borrow::{Borrow, Cow}, + fmt, + ops::Deref, + path::{Path, PathBuf}, + sync::Arc, +}; + +/// A file system path that is guaranteed to be relative and normalized. +/// +/// This type can be used to represent paths in a uniform way, regardless of +/// whether they refer to Windows or POSIX file systems, and regardless of +/// the host platform. +/// +/// Internally, paths are stored in POSIX ('/'-delimited) format, but they can +/// be displayed in either POSIX or Windows format. +/// +/// Relative paths are also guaranteed to be valid unicode. +#[repr(transparent)] +#[derive(PartialEq, Eq, Hash, Serialize)] +pub struct RelPath(str); + +/// An owned representation of a file system path that is guaranteed to be +/// relative and normalized. +/// +/// This type is to [`RelPath`] as [`std::path::PathBuf`] is to [`std::path::Path`] +#[derive(Clone, Serialize, Deserialize)] +pub struct RelPathBuf(String); + +impl RelPath { + /// Creates an empty [`RelPath`]. + pub fn empty() -> &'static Self { + Self::new_unchecked("") + } + + /// Converts a path with a given style into a [`RelPath`]. + /// + /// Returns an error if the path is absolute, or is not valid unicode. + /// + /// This method will normalize the path by removing `.` components, + /// processing `..` components, and removing trailing separators. It does + /// not allocate unless it's necessary to reformat the path. + #[track_caller] + pub fn new<'a>(path: &'a Path, path_style: PathStyle) -> Result> { + let mut path = path.to_str().context("non utf-8 path")?; + + let (prefixes, suffixes): (&[_], &[_]) = match path_style { + PathStyle::Posix => (&["./"], &['/']), + PathStyle::Windows => (&["./", ".\\"], &['/', '\\']), + }; + + while prefixes.iter().any(|prefix| path.starts_with(prefix)) { + path = &path[prefixes[0].len()..]; + } + while let Some(prefix) = path.strip_suffix(suffixes) + && !prefix.is_empty() + { + path = prefix; + } + + if is_absolute(&path, path_style) { + return Err(anyhow!("absolute path not allowed: {path:?}")); + } + + let mut string = Cow::Borrowed(path); + if path_style == PathStyle::Windows && path.contains('\\') { + string = Cow::Owned(string.as_ref().replace('\\', "/")) + } + + let mut result = match string { + Cow::Borrowed(string) => Cow::Borrowed(Self::new_unchecked(string)), + Cow::Owned(string) => Cow::Owned(RelPathBuf(string)), + }; + + if result + .components() + .any(|component| component == "" || component == "." || component == "..") + { + let mut normalized = RelPathBuf::new(); + for component in result.components() { + match component { + "" => {} + "." => {} + ".." => { + if !normalized.pop() { + return Err(anyhow!("path is not relative: {result:?}")); + } + } + other => normalized.push(RelPath::new_unchecked(other)), + } + } + result = Cow::Owned(normalized) + } + + Ok(result) + } + + /// Converts a path that is already normalized and uses '/' separators + /// into a [`RelPath`] . + /// + /// Returns an error if the path is not already in the correct format. + #[track_caller] + pub fn unix + ?Sized>(path: &S) -> anyhow::Result<&Self> { + let path = path.as_ref(); + match Self::new(path, PathStyle::Posix)? { + Cow::Borrowed(path) => Ok(path), + Cow::Owned(_) => Err(anyhow!("invalid relative path {path:?}")), + } + } + + fn new_unchecked(s: &str) -> &Self { + // Safety: `RelPath` is a transparent wrapper around `str`. + unsafe { &*(s as *const str as *const Self) } + } + + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + + pub fn components(&self) -> RelPathComponents<'_> { + RelPathComponents(&self.0) + } + + pub fn ancestors(&self) -> RelPathAncestors<'_> { + RelPathAncestors(Some(&self.0)) + } + + pub fn file_name(&self) -> Option<&str> { + self.components().next_back() + } + + pub fn file_stem(&self) -> Option<&str> { + Some(self.as_std_path().file_stem()?.to_str().unwrap()) + } + + pub fn extension(&self) -> Option<&str> { + Some(self.as_std_path().extension()?.to_str().unwrap()) + } + + pub fn parent(&self) -> Option<&Self> { + let mut components = self.components(); + components.next_back()?; + Some(components.rest()) + } + + pub fn starts_with(&self, other: &Self) -> bool { + self.strip_prefix(other).is_ok() + } + + pub fn ends_with(&self, other: &Self) -> bool { + if let Some(suffix) = self.0.strip_suffix(&other.0) { + if suffix.ends_with('/') { + return true; + } else if suffix.is_empty() { + return true; + } + } + false + } + + pub fn strip_prefix<'a>(&'a self, other: &Self) -> Result<&'a Self> { + if other.is_empty() { + return Ok(self); + } + if let Some(suffix) = self.0.strip_prefix(&other.0) { + if let Some(suffix) = suffix.strip_prefix('/') { + return Ok(Self::new_unchecked(suffix)); + } else if suffix.is_empty() { + return Ok(Self::empty()); + } + } + Err(anyhow!("failed to strip prefix: {other:?} from {self:?}")) + } + + pub fn len(&self) -> usize { + self.0.matches('/').count() + 1 + } + + pub fn last_n_components(&self, count: usize) -> Option<&Self> { + let len = self.len(); + if len >= count { + let mut components = self.components(); + for _ in 0..(len - count) { + components.next()?; + } + Some(components.rest()) + } else { + None + } + } + + pub fn join(&self, other: &Self) -> Arc { + let result = if self.0.is_empty() { + Cow::Borrowed(&other.0) + } else if other.0.is_empty() { + Cow::Borrowed(&self.0) + } else { + Cow::Owned(format!("{}/{}", &self.0, &other.0)) + }; + Arc::from(Self::new_unchecked(result.as_ref())) + } + + pub fn to_rel_path_buf(&self) -> RelPathBuf { + RelPathBuf(self.0.to_string()) + } + + pub fn into_arc(&self) -> Arc { + Arc::from(self) + } + + /// Convert the path into the wire representation. + pub fn to_proto(&self) -> String { + self.as_unix_str().to_owned() + } + + /// Load the path from its wire representation. + pub fn from_proto(path: &str) -> Result> { + Ok(Arc::from(Self::unix(path)?)) + } + + /// Convert the path into a string with the given path style. + /// + /// Whenever a path is presented to the user, it should be converted to + /// a string via this method. + pub fn display(&self, style: PathStyle) -> Cow<'_, str> { + match style { + PathStyle::Posix => Cow::Borrowed(&self.0), + PathStyle::Windows => Cow::Owned(self.0.replace('/', "\\")), + } + } + + /// Get the internal unix-style representation of the path. + /// + /// This should not be shown to the user. + pub fn as_unix_str(&self) -> &str { + &self.0 + } + + /// Interprets the path as a [`std::path::Path`], suitable for file system calls. + /// + /// This is guaranteed to be a valid path regardless of the host platform, because + /// the `/` is accepted as a path separator on windows. + /// + /// This should not be shown to the user. + pub fn as_std_path(&self) -> &Path { + Path::new(&self.0) + } +} + +impl ToOwned for RelPath { + type Owned = RelPathBuf; + + fn to_owned(&self) -> Self::Owned { + self.to_rel_path_buf() + } +} + +impl Borrow for RelPathBuf { + fn borrow(&self) -> &RelPath { + self.as_rel_path() + } +} + +impl PartialOrd for RelPath { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for RelPath { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + self.components().cmp(other.components()) + } +} + +impl fmt::Debug for RelPath { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Debug::fmt(&self.0, f) + } +} + +impl fmt::Debug for RelPathBuf { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Debug::fmt(&self.0, f) + } +} + +impl RelPathBuf { + pub fn new() -> Self { + Self(String::new()) + } + + pub fn pop(&mut self) -> bool { + if let Some(ix) = self.0.rfind('/') { + self.0.truncate(ix); + true + } else if !self.is_empty() { + self.0.clear(); + true + } else { + false + } + } + + pub fn push(&mut self, path: &RelPath) { + if !self.is_empty() { + self.0.push('/'); + } + self.0.push_str(&path.0); + } + + pub fn as_rel_path(&self) -> &RelPath { + RelPath::new_unchecked(self.0.as_str()) + } + + pub fn set_extension(&mut self, extension: &str) -> bool { + if let Some(filename) = self.file_name() { + let mut filename = PathBuf::from(filename); + filename.set_extension(extension); + self.pop(); + self.0.push_str(filename.to_str().unwrap()); + true + } else { + false + } + } +} + +impl Into> for RelPathBuf { + fn into(self) -> Arc { + Arc::from(self.as_rel_path()) + } +} + +impl AsRef for RelPathBuf { + fn as_ref(&self) -> &RelPath { + self.as_rel_path() + } +} + +impl Deref for RelPathBuf { + type Target = RelPath; + + fn deref(&self) -> &Self::Target { + self.as_ref() + } +} + +impl<'a> From<&'a RelPath> for Cow<'a, RelPath> { + fn from(value: &'a RelPath) -> Self { + Self::Borrowed(value) + } +} + +impl From<&RelPath> for Arc { + fn from(rel_path: &RelPath) -> Self { + let bytes: Arc = Arc::from(&rel_path.0); + unsafe { Arc::from_raw(Arc::into_raw(bytes) as *const RelPath) } + } +} + +#[cfg(any(test, feature = "test-support"))] +#[track_caller] +pub fn rel_path(path: &str) -> &RelPath { + RelPath::unix(path).unwrap() +} + +impl PartialEq for RelPath { + fn eq(&self, other: &str) -> bool { + self.0 == *other + } +} + +pub struct RelPathComponents<'a>(&'a str); + +pub struct RelPathAncestors<'a>(Option<&'a str>); + +const SEPARATOR: char = '/'; + +impl<'a> RelPathComponents<'a> { + pub fn rest(&self) -> &'a RelPath { + RelPath::new_unchecked(self.0) + } +} + +impl<'a> Iterator for RelPathComponents<'a> { + type Item = &'a str; + + fn next(&mut self) -> Option { + if let Some(sep_ix) = self.0.find(SEPARATOR) { + let (head, tail) = self.0.split_at(sep_ix); + self.0 = &tail[1..]; + Some(head) + } else if self.0.is_empty() { + None + } else { + let result = self.0; + self.0 = ""; + Some(result) + } + } +} + +impl<'a> Iterator for RelPathAncestors<'a> { + type Item = &'a RelPath; + + fn next(&mut self) -> Option { + let result = self.0?; + if let Some(sep_ix) = result.rfind(SEPARATOR) { + self.0 = Some(&result[..sep_ix]); + } else if !result.is_empty() { + self.0 = Some(""); + } else { + self.0 = None; + } + Some(RelPath::new_unchecked(result)) + } +} + +impl<'a> DoubleEndedIterator for RelPathComponents<'a> { + fn next_back(&mut self) -> Option { + if let Some(sep_ix) = self.0.rfind(SEPARATOR) { + let (head, tail) = self.0.split_at(sep_ix); + self.0 = head; + Some(&tail[1..]) + } else if self.0.is_empty() { + None + } else { + let result = self.0; + self.0 = ""; + Some(result) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use itertools::Itertools; + use pretty_assertions::assert_matches; + + #[test] + fn test_rel_path_new() { + assert!(RelPath::new(Path::new("/"), PathStyle::local()).is_err()); + assert!(RelPath::new(Path::new("//"), PathStyle::local()).is_err()); + assert!(RelPath::new(Path::new("/foo/"), PathStyle::local()).is_err()); + + let path = RelPath::new("foo/".as_ref(), PathStyle::local()).unwrap(); + assert_eq!(path, rel_path("foo").into()); + assert_matches!(path, Cow::Borrowed(_)); + + let path = RelPath::new("foo\\".as_ref(), PathStyle::Windows).unwrap(); + assert_eq!(path, rel_path("foo").into()); + assert_matches!(path, Cow::Borrowed(_)); + + assert_eq!( + RelPath::new("foo/bar/../baz/./quux/".as_ref(), PathStyle::local()) + .unwrap() + .as_ref(), + rel_path("foo/baz/quux") + ); + + let path = RelPath::new("./foo/bar".as_ref(), PathStyle::Posix).unwrap(); + assert_eq!(path.as_ref(), rel_path("foo/bar")); + assert_matches!(path, Cow::Borrowed(_)); + + let path = RelPath::new(".\\foo".as_ref(), PathStyle::Windows).unwrap(); + assert_eq!(path, rel_path("foo").into()); + assert_matches!(path, Cow::Borrowed(_)); + + let path = RelPath::new("./.\\./foo/\\/".as_ref(), PathStyle::Windows).unwrap(); + assert_eq!(path, rel_path("foo").into()); + assert_matches!(path, Cow::Borrowed(_)); + + let path = RelPath::new("foo/./bar".as_ref(), PathStyle::Posix).unwrap(); + assert_eq!(path.as_ref(), rel_path("foo/bar")); + assert_matches!(path, Cow::Owned(_)); + + let path = RelPath::new("./foo/bar".as_ref(), PathStyle::Windows).unwrap(); + assert_eq!(path.as_ref(), rel_path("foo/bar")); + assert_matches!(path, Cow::Borrowed(_)); + + let path = RelPath::new(".\\foo\\bar".as_ref(), PathStyle::Windows).unwrap(); + assert_eq!(path.as_ref(), rel_path("foo/bar")); + assert_matches!(path, Cow::Owned(_)); + } + + #[test] + fn test_rel_path_components() { + let path = rel_path("foo/bar/baz"); + assert_eq!( + path.components().collect::>(), + vec!["foo", "bar", "baz"] + ); + assert_eq!( + path.components().rev().collect::>(), + vec!["baz", "bar", "foo"] + ); + + let path = rel_path(""); + let mut components = path.components(); + assert_eq!(components.next(), None); + } + + #[test] + fn test_rel_path_ancestors() { + let path = rel_path("foo/bar/baz"); + let mut ancestors = path.ancestors(); + assert_eq!(ancestors.next(), Some(rel_path("foo/bar/baz"))); + assert_eq!(ancestors.next(), Some(rel_path("foo/bar"))); + assert_eq!(ancestors.next(), Some(rel_path("foo"))); + assert_eq!(ancestors.next(), Some(rel_path(""))); + assert_eq!(ancestors.next(), None); + + let path = rel_path("foo"); + let mut ancestors = path.ancestors(); + assert_eq!(ancestors.next(), Some(rel_path("foo"))); + assert_eq!(ancestors.next(), Some(RelPath::empty())); + assert_eq!(ancestors.next(), None); + + let path = RelPath::empty(); + let mut ancestors = path.ancestors(); + assert_eq!(ancestors.next(), Some(RelPath::empty())); + assert_eq!(ancestors.next(), None); + } + + #[test] + fn test_rel_path_parent() { + assert_eq!(rel_path("foo/bar/baz").parent(), Some(rel_path("foo/bar"))); + assert_eq!(rel_path("foo").parent(), Some(RelPath::empty())); + assert_eq!(rel_path("").parent(), None); + } + + #[test] + fn test_rel_path_partial_ord_is_compatible_with_std() { + let test_cases = ["a/b/c", "relative/path/with/dot.", "relative/path/with.dot"]; + for [lhs, rhs] in test_cases.iter().array_combinations::<2>() { + assert_eq!( + Path::new(lhs).cmp(Path::new(rhs)), + RelPath::unix(lhs) + .unwrap() + .cmp(&RelPath::unix(rhs).unwrap()) + ); + } + } + + #[test] + fn test_strip_prefix() { + let parent = rel_path(""); + let child = rel_path(".foo"); + + assert!(child.starts_with(parent)); + assert_eq!(child.strip_prefix(parent).unwrap(), child); + } + + #[test] + fn test_rel_path_constructors_absolute_path() { + assert!(RelPath::new(Path::new("/a/b"), PathStyle::Windows).is_err()); + assert!(RelPath::new(Path::new("\\a\\b"), PathStyle::Windows).is_err()); + assert!(RelPath::new(Path::new("/a/b"), PathStyle::Posix).is_err()); + assert!(RelPath::new(Path::new("C:/a/b"), PathStyle::Windows).is_err()); + assert!(RelPath::new(Path::new("C:\\a\\b"), PathStyle::Windows).is_err()); + assert!(RelPath::new(Path::new("C:/a/b"), PathStyle::Posix).is_ok()); + } + + #[test] + fn test_pop() { + let mut path = rel_path("a/b").to_rel_path_buf(); + path.pop(); + assert_eq!(path.as_rel_path().as_unix_str(), "a"); + path.pop(); + assert_eq!(path.as_rel_path().as_unix_str(), ""); + path.pop(); + assert_eq!(path.as_rel_path().as_unix_str(), ""); + } +} diff --git a/third_party/gpui_support/gpui_util/src/schemars.rs b/third_party/gpui_support/gpui_util/src/schemars.rs new file mode 100644 index 0000000..9314eda --- /dev/null +++ b/third_party/gpui_support/gpui_util/src/schemars.rs @@ -0,0 +1,55 @@ +use schemars::{JsonSchema, transform::transform_subschemas}; + +const DEFS_PATH: &str = "#/$defs/"; + +/// Replaces the JSON schema definition for some type if it is in use (in the definitions list), and +/// returns a reference to it. +/// +/// This asserts that JsonSchema::schema_name() + "2" does not exist because this indicates that +/// there are multiple types that use this name, and unfortunately schemars APIs do not support +/// resolving this ambiguity - see +/// +/// This takes a closure for `schema` because some settings types are not available on the remote +/// server, and so will crash when attempting to access e.g. GlobalThemeRegistry. +pub fn replace_subschema( + generator: &mut schemars::SchemaGenerator, + schema: impl Fn() -> schemars::Schema, +) -> schemars::Schema { + let schema_name = T::schema_name(); + let definitions = generator.definitions_mut(); + assert!(!definitions.contains_key(&format!("{schema_name}2"))); + assert!(definitions.contains_key(schema_name.as_ref())); + definitions.insert(schema_name.to_string(), schema().to_value()); + schemars::Schema::new_ref(format!("{DEFS_PATH}{schema_name}")) +} + +/// Adds a new JSON schema definition and returns a reference to it. **Panics** if the name is +/// already in use. +pub fn add_new_subschema( + generator: &mut schemars::SchemaGenerator, + name: &str, + schema: serde_json::Value, +) -> schemars::Schema { + let old_definition = generator.definitions_mut().insert(name.to_string(), schema); + assert_eq!(old_definition, None); + schemars::Schema::new_ref(format!("{DEFS_PATH}{name}")) +} + +/// Defaults `additionalProperties` to `true`, as if `#[schemars(deny_unknown_fields)]` was on every +/// struct. Skips structs that have `additionalProperties` set (such as if #[serde(flatten)] is used +/// on a map). +#[derive(Clone)] +pub struct DefaultDenyUnknownFields; + +impl schemars::transform::Transform for DefaultDenyUnknownFields { + fn transform(&mut self, schema: &mut schemars::Schema) { + if let Some(object) = schema.as_object_mut() + && object.contains_key("properties") + && !object.contains_key("additionalProperties") + && !object.contains_key("unevaluatedProperties") + { + object.insert("additionalProperties".to_string(), false.into()); + } + transform_subschemas(self, schema); + } +} diff --git a/third_party/gpui_support/gpui_util/src/serde.rs b/third_party/gpui_support/gpui_util/src/serde.rs new file mode 100644 index 0000000..4aa4bb1 --- /dev/null +++ b/third_party/gpui_support/gpui_util/src/serde.rs @@ -0,0 +1,7 @@ +pub const fn default_true() -> bool { + true +} + +pub fn is_default(value: &T) -> bool { + *value == T::default() +} diff --git a/third_party/gpui_support/gpui_util/src/shell.rs b/third_party/gpui_support/gpui_util/src/shell.rs new file mode 100644 index 0000000..22e07ac --- /dev/null +++ b/third_party/gpui_support/gpui_util/src/shell.rs @@ -0,0 +1,406 @@ +use serde::{Deserialize, Serialize}; +use std::{borrow::Cow, fmt, path::Path, sync::LazyLock}; + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum ShellKind { + #[default] + Posix, + Csh, + Tcsh, + Rc, + Fish, + PowerShell, + Nushell, + Cmd, + Xonsh, +} + +pub fn get_system_shell() -> String { + if cfg!(windows) { + get_windows_system_shell() + } else { + std::env::var("SHELL").unwrap_or("/bin/sh".to_string()) + } +} + +pub fn get_default_system_shell() -> String { + if cfg!(windows) { + get_windows_system_shell() + } else { + "/bin/sh".to_string() + } +} + +/// Get the default system shell, preferring git-bash on Windows. +pub fn get_default_system_shell_preferring_bash() -> String { + if cfg!(windows) { + get_windows_git_bash().unwrap_or_else(|| get_windows_system_shell()) + } else { + "/bin/sh".to_string() + } +} + +pub fn get_windows_git_bash() -> Option { + static GIT_BASH: LazyLock> = LazyLock::new(|| { + // /path/to/git/cmd/git.exe/../../bin/bash.exe + let git = which::which("git").ok()?; + let git_bash = git.parent()?.parent()?.join("bin").join("bash.exe"); + if git_bash.is_file() { + log::info!("Found git-bash at {}", git_bash.display()); + Some(git_bash.to_string_lossy().to_string()) + } else { + None + } + }); + + (*GIT_BASH).clone() +} + +pub fn get_windows_system_shell() -> String { + use std::path::PathBuf; + + fn find_pwsh_in_programfiles(find_alternate: bool, find_preview: bool) -> Option { + #[cfg(target_pointer_width = "64")] + let env_var = if find_alternate { + "ProgramFiles(x86)" + } else { + "ProgramFiles" + }; + + #[cfg(target_pointer_width = "32")] + let env_var = if find_alternate { + "ProgramW6432" + } else { + "ProgramFiles" + }; + + let install_base_dir = PathBuf::from(std::env::var_os(env_var)?).join("PowerShell"); + install_base_dir + .read_dir() + .ok()? + .filter_map(Result::ok) + .filter(|entry| matches!(entry.file_type(), Ok(ft) if ft.is_dir())) + .filter_map(|entry| { + let dir_name = entry.file_name(); + let dir_name = dir_name.to_string_lossy(); + + let version = if find_preview { + let dash_index = dir_name.find('-')?; + if &dir_name[dash_index + 1..] != "preview" { + return None; + }; + dir_name[..dash_index].parse::().ok()? + } else { + dir_name.parse::().ok()? + }; + + let exe_path = entry.path().join("pwsh.exe"); + if exe_path.exists() { + Some((version, exe_path)) + } else { + None + } + }) + .max_by_key(|(version, _)| *version) + .map(|(_, path)| path) + } + + fn find_pwsh_in_msix(find_preview: bool) -> Option { + let msix_app_dir = + PathBuf::from(std::env::var_os("LOCALAPPDATA")?).join("Microsoft\\WindowsApps"); + if !msix_app_dir.exists() { + return None; + } + + let prefix = if find_preview { + "Microsoft.PowerShellPreview_" + } else { + "Microsoft.PowerShell_" + }; + msix_app_dir + .read_dir() + .ok()? + .filter_map(|entry| { + let entry = entry.ok()?; + if !matches!(entry.file_type(), Ok(ft) if ft.is_dir()) { + return None; + } + + if !entry.file_name().to_string_lossy().starts_with(prefix) { + return None; + } + + let exe_path = entry.path().join("pwsh.exe"); + exe_path.exists().then_some(exe_path) + }) + .next() + } + + fn find_pwsh_in_scoop() -> Option { + let pwsh_exe = + PathBuf::from(std::env::var_os("USERPROFILE")?).join("scoop\\shims\\pwsh.exe"); + pwsh_exe.exists().then_some(pwsh_exe) + } + + static SYSTEM_SHELL: LazyLock = LazyLock::new(|| { + find_pwsh_in_programfiles(false, false) + .or_else(|| find_pwsh_in_programfiles(true, false)) + .or_else(|| find_pwsh_in_msix(false)) + .or_else(|| find_pwsh_in_programfiles(false, true)) + .or_else(|| find_pwsh_in_msix(true)) + .or_else(|| find_pwsh_in_programfiles(true, true)) + .or_else(find_pwsh_in_scoop) + .map(|p| p.to_string_lossy().into_owned()) + .unwrap_or("powershell.exe".to_string()) + }); + + (*SYSTEM_SHELL).clone() +} + +impl fmt::Display for ShellKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ShellKind::Posix => write!(f, "sh"), + ShellKind::Csh => write!(f, "csh"), + ShellKind::Tcsh => write!(f, "tcsh"), + ShellKind::Fish => write!(f, "fish"), + ShellKind::PowerShell => write!(f, "powershell"), + ShellKind::Nushell => write!(f, "nu"), + ShellKind::Cmd => write!(f, "cmd"), + ShellKind::Rc => write!(f, "rc"), + ShellKind::Xonsh => write!(f, "xonsh"), + } + } +} + +impl ShellKind { + pub fn system() -> Self { + Self::new(&get_system_shell(), cfg!(windows)) + } + + pub fn new(program: impl AsRef, is_windows: bool) -> Self { + let program = program.as_ref(); + let program = program + .file_stem() + .unwrap_or_else(|| program.as_os_str()) + .to_string_lossy(); + + if program == "powershell" || program == "pwsh" { + ShellKind::PowerShell + } else if program == "cmd" { + ShellKind::Cmd + } else if program == "nu" { + ShellKind::Nushell + } else if program == "fish" { + ShellKind::Fish + } else if program == "csh" { + ShellKind::Csh + } else if program == "tcsh" { + ShellKind::Tcsh + } else if program == "rc" { + ShellKind::Rc + } else if program == "xonsh" { + ShellKind::Xonsh + } else if program == "sh" || program == "bash" { + ShellKind::Posix + } else { + if is_windows { + ShellKind::PowerShell + } else { + // Some other shell detected, the user might install and use a + // unix-like shell. + ShellKind::Posix + } + } + } + + pub fn to_shell_variable(self, input: &str) -> String { + match self { + Self::PowerShell => Self::to_powershell_variable(input), + Self::Cmd => Self::to_cmd_variable(input), + Self::Posix => input.to_owned(), + Self::Fish => input.to_owned(), + Self::Csh => input.to_owned(), + Self::Tcsh => input.to_owned(), + Self::Rc => input.to_owned(), + Self::Nushell => Self::to_nushell_variable(input), + Self::Xonsh => input.to_owned(), + } + } + + fn to_cmd_variable(input: &str) -> String { + if let Some(var_str) = input.strip_prefix("${") { + if var_str.find(':').is_none() { + // If the input starts with "${", remove the trailing "}" + format!("%{}%", &var_str[..var_str.len() - 1]) + } else { + // `${SOME_VAR:-SOME_DEFAULT}`, we currently do not handle this situation, + // which will result in the task failing to run in such cases. + input.into() + } + } else if let Some(var_str) = input.strip_prefix('$') { + // If the input starts with "$", directly append to "$env:" + format!("%{}%", var_str) + } else { + // If no prefix is found, return the input as is + input.into() + } + } + + fn to_powershell_variable(input: &str) -> String { + if let Some(var_str) = input.strip_prefix("${") { + if var_str.find(':').is_none() { + // If the input starts with "${", remove the trailing "}" + format!("$env:{}", &var_str[..var_str.len() - 1]) + } else { + // `${SOME_VAR:-SOME_DEFAULT}`, we currently do not handle this situation, + // which will result in the task failing to run in such cases. + input.into() + } + } else if let Some(var_str) = input.strip_prefix('$') { + // If the input starts with "$", directly append to "$env:" + format!("$env:{}", var_str) + } else { + // If no prefix is found, return the input as is + input.into() + } + } + + fn to_nushell_variable(input: &str) -> String { + let mut result = String::new(); + let mut source = input; + let mut is_start = true; + + loop { + match source.chars().next() { + None => return result, + Some('$') => { + source = Self::parse_nushell_var(&source[1..], &mut result, is_start); + is_start = false; + } + Some(_) => { + is_start = false; + let chunk_end = source.find('$').unwrap_or(source.len()); + let (chunk, rest) = source.split_at(chunk_end); + result.push_str(chunk); + source = rest; + } + } + } + } + + fn parse_nushell_var<'a>(source: &'a str, text: &mut String, is_start: bool) -> &'a str { + if source.starts_with("env.") { + text.push('$'); + return source; + } + + match source.chars().next() { + Some('{') => { + let source = &source[1..]; + if let Some(end) = source.find('}') { + let var_name = &source[..end]; + if !var_name.is_empty() { + if !is_start { + text.push_str("("); + } + text.push_str("$env."); + text.push_str(var_name); + if !is_start { + text.push_str(")"); + } + &source[end + 1..] + } else { + text.push_str("${}"); + &source[end + 1..] + } + } else { + text.push_str("${"); + source + } + } + Some(c) if c.is_alphabetic() || c == '_' => { + let end = source + .find(|c: char| !c.is_alphanumeric() && c != '_') + .unwrap_or(source.len()); + let var_name = &source[..end]; + if !is_start { + text.push_str("("); + } + text.push_str("$env."); + text.push_str(var_name); + if !is_start { + text.push_str(")"); + } + &source[end..] + } + _ => { + text.push('$'); + source + } + } + } + + pub fn args_for_shell(&self, interactive: bool, combined_command: String) -> Vec { + match self { + ShellKind::PowerShell => vec!["-C".to_owned(), combined_command], + ShellKind::Cmd => vec!["/C".to_owned(), combined_command], + ShellKind::Posix + | ShellKind::Nushell + | ShellKind::Fish + | ShellKind::Csh + | ShellKind::Tcsh + | ShellKind::Rc + | ShellKind::Xonsh => interactive + .then(|| "-i".to_owned()) + .into_iter() + .chain(["-c".to_owned(), combined_command]) + .collect(), + } + } + + pub const fn command_prefix(&self) -> Option { + match self { + ShellKind::PowerShell => Some('&'), + ShellKind::Nushell => Some('^'), + _ => None, + } + } + + pub const fn sequential_commands_separator(&self) -> char { + match self { + ShellKind::Cmd => '&', + _ => ';', + } + } + + pub fn try_quote<'a>(&self, arg: &'a str) -> Option> { + shlex::try_quote(arg).ok().map(|arg| match self { + // If we are running in PowerShell, we want to take extra care when escaping strings. + // In particular, we want to escape strings with a backtick (`) rather than a backslash (\). + // TODO double escaping backslashes is not necessary in PowerShell and probably CMD + ShellKind::PowerShell => Cow::Owned(arg.replace("\\\"", "`\"")), + _ => arg, + }) + } + + pub const fn activate_keyword(&self) -> &'static str { + match self { + ShellKind::Cmd => "", + ShellKind::Nushell => "overlay use", + ShellKind::PowerShell => ".", + ShellKind::Fish => "source", + ShellKind::Csh => "source", + ShellKind::Tcsh => "source", + ShellKind::Posix | ShellKind::Rc => "source", + ShellKind::Xonsh => "source", + } + } + + pub const fn clear_screen_command(&self) -> &'static str { + match self { + ShellKind::Cmd => "cls", + _ => "clear", + } + } +} diff --git a/third_party/gpui_support/gpui_util/src/shell_env.rs b/third_party/gpui_support/gpui_util/src/shell_env.rs new file mode 100644 index 0000000..a82bea1 --- /dev/null +++ b/third_party/gpui_support/gpui_util/src/shell_env.rs @@ -0,0 +1,236 @@ +use std::path::Path; + +use anyhow::{Context as _, Result}; +use collections::HashMap; + +use crate::shell::ShellKind; + +pub fn print_env() { + let env_vars: HashMap = std::env::vars().collect(); + let json = serde_json::to_string_pretty(&env_vars).unwrap_or_else(|err| { + eprintln!("Error serializing environment variables: {}", err); + std::process::exit(1); + }); + println!("{}", json); +} + +/// Capture all environment variables from the login shell in the given directory. +pub async fn capture( + shell_path: impl AsRef, + args: &[String], + directory: impl AsRef, +) -> Result> { + #[cfg(windows)] + return capture_windows(shell_path.as_ref(), args, directory.as_ref()).await; + #[cfg(unix)] + return capture_unix(shell_path.as_ref(), args, directory.as_ref()).await; +} + +#[cfg(unix)] +async fn capture_unix( + shell_path: &Path, + args: &[String], + directory: &Path, +) -> Result> { + use std::os::unix::process::CommandExt; + use std::process::Stdio; + + let zed_path = super::get_shell_safe_zed_path()?; + let shell_kind = ShellKind::new(shell_path, false); + + let mut command_string = String::new(); + let mut command = std::process::Command::new(shell_path); + command.args(args); + // In some shells, file descriptors greater than 2 cannot be used in interactive mode, + // so file descriptor 0 (stdin) is used instead. This impacts zsh, old bash; perhaps others. + // See: https://github.com/zed-industries/zed/pull/32136#issuecomment-2999645482 + const FD_STDIN: std::os::fd::RawFd = 0; + const FD_STDOUT: std::os::fd::RawFd = 1; + const FD_STDERR: std::os::fd::RawFd = 2; + + let (fd_num, redir) = match shell_kind { + ShellKind::Rc => (FD_STDIN, format!(">[1={}]", FD_STDIN)), // `[1=0]` + ShellKind::Nushell | ShellKind::Tcsh => (FD_STDOUT, "".to_string()), + // xonsh doesn't support redirecting to stdin, and control sequences are printed to + // stdout on startup + ShellKind::Xonsh => (FD_STDERR, "o>e".to_string()), + _ => (FD_STDIN, format!(">&{}", FD_STDIN)), // `>&0` + }; + command.stdin(Stdio::null()); + command.stdout(Stdio::piped()); + command.stderr(Stdio::piped()); + + match shell_kind { + ShellKind::Csh | ShellKind::Tcsh => { + // For csh/tcsh, login shell requires passing `-` as 0th argument (instead of `-l`) + command.arg0("-"); + } + ShellKind::Fish => { + // in fish, asdf, direnv attach to the `fish_prompt` event + command_string.push_str("emit fish_prompt;"); + command.arg("-l"); + } + _ => { + command.arg("-l"); + } + } + // cd into the directory, triggering directory specific side-effects (asdf, direnv, etc) + command_string.push_str(&format!("cd '{}';", directory.display())); + if let Some(prefix) = shell_kind.command_prefix() { + command_string.push(prefix); + } + command_string.push_str(&format!("{} --printenv {}", zed_path, redir)); + command.args(["-i", "-c", &command_string]); + + super::set_pre_exec_to_start_new_session(&mut command); + + let (env_output, process_output) = spawn_and_read_fd(command, fd_num).await?; + let env_output = String::from_utf8_lossy(&env_output); + + anyhow::ensure!( + process_output.status.success(), + "login shell exited with {}. stdout: {:?}, stderr: {:?}", + process_output.status, + String::from_utf8_lossy(&process_output.stdout), + String::from_utf8_lossy(&process_output.stderr), + ); + + // Parse the JSON output from zed --printenv + let env_map: collections::HashMap = serde_json::from_str(&env_output) + .with_context(|| "Failed to deserialize environment variables from json")?; + Ok(env_map) +} + +#[cfg(unix)] +async fn spawn_and_read_fd( + mut command: std::process::Command, + child_fd: std::os::fd::RawFd, +) -> anyhow::Result<(Vec, std::process::Output)> { + use command_fds::{CommandFdExt, FdMapping}; + use std::io::Read; + + let (mut reader, writer) = std::io::pipe()?; + + command.fd_mappings(vec![FdMapping { + parent_fd: writer.into(), + child_fd, + }])?; + + let process = smol::process::Command::from(command).spawn()?; + + let mut buffer = Vec::new(); + reader.read_to_end(&mut buffer)?; + + Ok((buffer, process.output().await?)) +} + +#[cfg(windows)] +async fn capture_windows( + shell_path: &Path, + _args: &[String], + directory: &Path, +) -> Result> { + use std::process::Stdio; + + let zed_path = + std::env::current_exe().context("Failed to determine current zed executable path.")?; + + let shell_kind = ShellKind::new(shell_path, true); + let env_output = match shell_kind { + ShellKind::Posix + | ShellKind::Csh + | ShellKind::Tcsh + | ShellKind::Rc + | ShellKind::Fish + | ShellKind::Xonsh => { + return Err(anyhow::anyhow!("unsupported shell kind")); + } + ShellKind::PowerShell => { + let output = crate::command::new_smol_command(shell_path) + .args([ + "-NonInteractive", + "-NoProfile", + "-Command", + &format!( + "Set-Location '{}'; & '{}' --printenv", + directory.display(), + zed_path.display() + ), + ]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .await?; + + anyhow::ensure!( + output.status.success(), + "PowerShell command failed with {}. stdout: {:?}, stderr: {:?}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + output + } + ShellKind::Nushell => { + let output = crate::command::new_smol_command(shell_path) + .args([ + "-c", + &format!( + "cd '{}'; {}{} --printenv", + directory.display(), + shell_kind + .command_prefix() + .map(|prefix| prefix.to_string()) + .unwrap_or_default(), + zed_path.display() + ), + ]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .await?; + + anyhow::ensure!( + output.status.success(), + "Nushell command failed with {}. stdout: {:?}, stderr: {:?}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + output + } + ShellKind::Cmd => { + let output = crate::command::new_smol_command(shell_path) + .args([ + "/c", + &format!( + "cd '{}'; {} --printenv", + directory.display(), + zed_path.display() + ), + ]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .await?; + + anyhow::ensure!( + output.status.success(), + "Cmd command failed with {}. stdout: {:?}, stderr: {:?}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + output + } + }; + + let env_output = String::from_utf8_lossy(&env_output.stdout); + + // Parse the JSON output from zed --printenv + serde_json::from_str(&env_output) + .with_context(|| "Failed to deserialize environment variables from json") +} diff --git a/third_party/gpui_support/gpui_util/src/size.rs b/third_party/gpui_support/gpui_util/src/size.rs new file mode 100644 index 0000000..c6ecebd --- /dev/null +++ b/third_party/gpui_support/gpui_util/src/size.rs @@ -0,0 +1,46 @@ +pub fn format_file_size(size: u64, use_decimal: bool) -> String { + if use_decimal { + if size < 1000 { + format!("{size}B") + } else if size < 1000 * 1000 { + format!("{:.1}KB", size as f64 / 1000.0) + } else { + format!("{:.1}MB", size as f64 / (1000.0 * 1000.0)) + } + } else if size < 1024 { + format!("{size}B") + } else if size < 1024 * 1024 { + format!("{:.1}KiB", size as f64 / 1024.0) + } else { + format!("{:.1}MiB", size as f64 / (1024.0 * 1024.0)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_format_file_size_decimal() { + assert_eq!(format_file_size(0, true), "0B"); + assert_eq!(format_file_size(999, true), "999B"); + assert_eq!(format_file_size(1000, true), "1.0KB"); + assert_eq!(format_file_size(1500, true), "1.5KB"); + assert_eq!(format_file_size(999999, true), "1000.0KB"); + assert_eq!(format_file_size(1000000, true), "1.0MB"); + assert_eq!(format_file_size(1500000, true), "1.5MB"); + assert_eq!(format_file_size(10000000, true), "10.0MB"); + } + + #[test] + fn test_format_file_size_binary() { + assert_eq!(format_file_size(0, false), "0B"); + assert_eq!(format_file_size(1023, false), "1023B"); + assert_eq!(format_file_size(1024, false), "1.0KiB"); + assert_eq!(format_file_size(1536, false), "1.5KiB"); + assert_eq!(format_file_size(1048575, false), "1024.0KiB"); + assert_eq!(format_file_size(1048576, false), "1.0MiB"); + assert_eq!(format_file_size(1572864, false), "1.5MiB"); + assert_eq!(format_file_size(10485760, false), "10.0MiB"); + } +} diff --git a/third_party/gpui_support/gpui_util/src/test.rs b/third_party/gpui_support/gpui_util/src/test.rs new file mode 100644 index 0000000..0a251a1 --- /dev/null +++ b/third_party/gpui_support/gpui_util/src/test.rs @@ -0,0 +1,81 @@ +mod assertions; +mod marked_text; + +use git2; +use std::{ + ffi::OsStr, + path::{Path, PathBuf}, +}; +use tempfile::TempDir; + +pub use assertions::*; +pub use marked_text::*; + +pub struct TempTree { + _temp_dir: TempDir, + path: PathBuf, +} + +impl TempTree { + pub fn new(tree: serde_json::Value) -> Self { + let dir = TempDir::new().unwrap(); + let path = std::fs::canonicalize(dir.path()).unwrap(); + write_tree(path.as_path(), tree); + + Self { + _temp_dir: dir, + path, + } + } + + pub fn path(&self) -> &Path { + self.path.as_path() + } +} + +fn write_tree(path: &Path, tree: serde_json::Value) { + use serde_json::Value; + use std::fs; + + if let Value::Object(map) = tree { + for (name, contents) in map { + let mut path = PathBuf::from(path); + path.push(name); + match contents { + Value::Object(_) => { + fs::create_dir(&path).unwrap(); + + if path.file_name() == Some(OsStr::new(".git")) { + git2::Repository::init(path.parent().unwrap()).unwrap(); + } + + write_tree(&path, contents); + } + Value::Null => { + fs::create_dir(&path).unwrap(); + } + Value::String(contents) => { + fs::write(&path, contents).unwrap(); + } + _ => { + panic!("JSON object must contain only objects, strings, or null"); + } + } + } + } else { + panic!("You must pass a JSON object to this helper") + } +} + +pub fn sample_text(rows: usize, cols: usize, start_char: char) -> String { + let mut text = String::new(); + for row in 0..rows { + let c: char = (start_char as u32 + row as u32) as u8 as char; + let mut line = c.to_string().repeat(cols); + if row < rows - 1 { + line.push('\n'); + } + text += &line; + } + text +} diff --git a/third_party/gpui_support/gpui_util/src/test/assertions.rs b/third_party/gpui_support/gpui_util/src/test/assertions.rs new file mode 100644 index 0000000..afb1397 --- /dev/null +++ b/third_party/gpui_support/gpui_util/src/test/assertions.rs @@ -0,0 +1,62 @@ +pub enum SetEqError { + LeftMissing(T), + RightMissing(T), +} + +impl SetEqError { + pub fn map R>(self, update: F) -> SetEqError { + match self { + SetEqError::LeftMissing(missing) => SetEqError::LeftMissing(update(missing)), + SetEqError::RightMissing(missing) => SetEqError::RightMissing(update(missing)), + } + } +} + +#[macro_export] +macro_rules! set_eq { + ($left:expr,$right:expr) => {{ + use util::test::*; + + let left = $left; + let right = $right; + + let mut result = Ok(()); + for right_value in right.iter() { + if !left.contains(right_value) { + result = Err(SetEqError::LeftMissing(right_value.clone())); + break; + } + } + + if result.is_ok() { + for left_value in left.iter() { + if !right.contains(left_value) { + result = Err(SetEqError::RightMissing(left_value.clone())); + } + } + } + + result + }}; +} + +#[macro_export] +macro_rules! assert_set_eq { + ($left:expr,$right:expr) => {{ + use util::test::*; + use util::set_eq; + + let left = $left; + let right = $right; + + match set_eq!(&left, &right) { + Err(SetEqError::LeftMissing(missing)) => { + panic!("assertion failed: `(left == right)`\n left: {:?}\nright: {:?}\nleft does not contain {:?}", &left, &right, &missing); + }, + Err(SetEqError::RightMissing(missing)) => { + panic!("assertion failed: `(left == right)`\n left: {:?}\nright: {:?}\nright does not contain {:?}", &left, &right, &missing); + }, + _ => {} + } + }}; +} diff --git a/third_party/gpui_support/gpui_util/src/test/marked_text.rs b/third_party/gpui_support/gpui_util/src/test/marked_text.rs new file mode 100644 index 0000000..282a477 --- /dev/null +++ b/third_party/gpui_support/gpui_util/src/test/marked_text.rs @@ -0,0 +1,281 @@ +use collections::HashMap; +use std::{cmp::Ordering, ops::Range}; + +/// Construct a string and a list of offsets within that string using a single +/// string containing embedded position markers. +pub fn marked_text_offsets_by( + marked_text: &str, + markers: Vec, +) -> (String, HashMap>) { + let mut extracted_markers: HashMap> = Default::default(); + let mut unmarked_text = String::new(); + + for char in marked_text.chars() { + if markers.contains(&char) { + let char_offsets = extracted_markers.entry(char).or_default(); + char_offsets.push(unmarked_text.len()); + } else { + unmarked_text.push(char); + } + } + + (unmarked_text, extracted_markers) +} + +/// Construct a string and a list of ranges within that string using a single +/// string containing embedded range markers, using arbitrary characters as +/// range markers. By using multiple different range markers, you can construct +/// ranges that overlap each other. +/// +/// The returned ranges will be grouped by their range marking characters. +pub fn marked_text_ranges_by( + marked_text: &str, + markers: Vec, +) -> (String, HashMap>>) { + let all_markers = markers.iter().flat_map(|m| m.markers()).collect(); + + let (unmarked_text, mut marker_offsets) = marked_text_offsets_by(marked_text, all_markers); + let range_lookup = markers + .into_iter() + .map(|marker| { + ( + marker.clone(), + match marker { + TextRangeMarker::Empty(empty_marker_char) => marker_offsets + .remove(&empty_marker_char) + .unwrap_or_default() + .into_iter() + .map(|empty_index| empty_index..empty_index) + .collect::>>(), + TextRangeMarker::Range(start_marker, end_marker) => { + let starts = marker_offsets.remove(&start_marker).unwrap_or_default(); + let ends = marker_offsets.remove(&end_marker).unwrap_or_default(); + assert_eq!(starts.len(), ends.len(), "marked ranges are unbalanced"); + starts + .into_iter() + .zip(ends) + .map(|(start, end)| { + assert!(end >= start, "marked ranges must be disjoint"); + start..end + }) + .collect::>>() + } + TextRangeMarker::ReverseRange(start_marker, end_marker) => { + let starts = marker_offsets.remove(&start_marker).unwrap_or_default(); + let ends = marker_offsets.remove(&end_marker).unwrap_or_default(); + assert_eq!(starts.len(), ends.len(), "marked ranges are unbalanced"); + starts + .into_iter() + .zip(ends) + .map(|(start, end)| { + assert!(end >= start, "marked ranges must be disjoint"); + end..start + }) + .collect::>>() + } + }, + ) + }) + .collect(); + + (unmarked_text, range_lookup) +} + +/// Construct a string and a list of ranges within that string using a single +/// string containing embedded range markers. The characters used to mark the +/// ranges are as follows: +/// +/// 1. To mark a range of text, surround it with the `«` and `»` angle brackets, +/// which can be typed on a US keyboard with the `alt-|` and `alt-shift-|` keys. +/// +/// ```text +/// foo «selected text» bar +/// ``` +/// +/// 2. To mark a single position in the text, use the `ˇ` caron, +/// which can be typed on a US keyboard with the `alt-shift-t` key. +/// +/// ```text +/// the cursors are hereˇ and hereˇ. +/// ``` +/// +/// 3. To mark a range whose direction is meaningful (like a selection), +/// put a caron character beside one of its bounds, on the inside: +/// +/// ```text +/// one «ˇreversed» selection and one «forwardˇ» selection +/// ``` +/// +/// Any • characters in the input string will be replaced with spaces. This makes +/// it easier to test cases with trailing spaces, which tend to get trimmed from the +/// source code. +#[track_caller] +pub fn marked_text_ranges( + marked_text: &str, + ranges_are_directed: bool, +) -> (String, Vec>) { + let mut unmarked_text = String::with_capacity(marked_text.len()); + let mut ranges = Vec::new(); + let mut prev_marked_ix = 0; + let mut current_range_start = None; + let mut current_range_cursor = None; + + let marked_text = marked_text.replace('•', " "); + for (marked_ix, marker) in marked_text.match_indices(&['«', '»', 'ˇ']) { + unmarked_text.push_str(&marked_text[prev_marked_ix..marked_ix]); + let unmarked_len = unmarked_text.len(); + let len = marker.len(); + prev_marked_ix = marked_ix + len; + + match marker { + "ˇ" => { + if current_range_start.is_some() { + if current_range_cursor.is_some() { + panic!("duplicate point marker 'ˇ' at index {marked_ix}"); + } + + current_range_cursor = Some(unmarked_len); + } else { + ranges.push(unmarked_len..unmarked_len); + } + } + "«" => { + if current_range_start.is_some() { + panic!("unexpected range start marker '«' at index {marked_ix}"); + } + current_range_start = Some(unmarked_len); + } + "»" => { + let current_range_start = if let Some(start) = current_range_start.take() { + start + } else { + panic!("unexpected range end marker '»' at index {marked_ix}"); + }; + + let mut reversed = false; + if let Some(current_range_cursor) = current_range_cursor.take() { + if current_range_cursor == current_range_start { + reversed = true; + } else if current_range_cursor != unmarked_len { + panic!("unexpected 'ˇ' marker in the middle of a range"); + } + } else if ranges_are_directed { + panic!("missing 'ˇ' marker to indicate range direction"); + } + + ranges.push(if reversed { + unmarked_len..current_range_start + } else { + current_range_start..unmarked_len + }); + } + _ => unreachable!(), + } + } + + unmarked_text.push_str(&marked_text[prev_marked_ix..]); + (unmarked_text, ranges) +} + +#[track_caller] +pub fn marked_text_offsets(marked_text: &str) -> (String, Vec) { + let (text, ranges) = marked_text_ranges(marked_text, false); + ( + text, + ranges + .into_iter() + .map(|range| { + assert_eq!(range.start, range.end); + range.start + }) + .collect(), + ) +} + +pub fn generate_marked_text( + unmarked_text: &str, + ranges: &[Range], + indicate_cursors: bool, +) -> String { + let mut marked_text = unmarked_text.to_string(); + for range in ranges.iter().rev() { + if indicate_cursors { + match range.start.cmp(&range.end) { + Ordering::Less => { + marked_text.insert_str(range.end, "ˇ»"); + marked_text.insert(range.start, '«'); + } + Ordering::Equal => { + marked_text.insert(range.start, 'ˇ'); + } + Ordering::Greater => { + marked_text.insert(range.start, '»'); + marked_text.insert_str(range.end, "«ˇ"); + } + } + } else { + match range.start.cmp(&range.end) { + Ordering::Equal => { + marked_text.insert(range.start, 'ˇ'); + } + _ => { + marked_text.insert(range.end, '»'); + marked_text.insert(range.start, '«'); + } + } + } + } + marked_text +} + +#[derive(Clone, Eq, PartialEq, Hash)] +pub enum TextRangeMarker { + Empty(char), + Range(char, char), + ReverseRange(char, char), +} + +impl TextRangeMarker { + fn markers(&self) -> Vec { + match self { + Self::Empty(m) => vec![*m], + Self::Range(l, r) => vec![*l, *r], + Self::ReverseRange(l, r) => vec![*l, *r], + } + } +} + +impl From for TextRangeMarker { + fn from(marker: char) -> Self { + Self::Empty(marker) + } +} + +impl From<(char, char)> for TextRangeMarker { + fn from((left_marker, right_marker): (char, char)) -> Self { + Self::Range(left_marker, right_marker) + } +} + +#[cfg(test)] +mod tests { + use super::{generate_marked_text, marked_text_ranges}; + + #[allow(clippy::reversed_empty_ranges)] + #[test] + fn test_marked_text() { + let (text, ranges) = marked_text_ranges("one «ˇtwo» «threeˇ» «ˇfour» fiveˇ six", true); + + assert_eq!(text, "one two three four five six"); + assert_eq!(ranges.len(), 4); + assert_eq!(ranges[0], 7..4); + assert_eq!(ranges[1], 8..13); + assert_eq!(ranges[2], 18..14); + assert_eq!(ranges[3], 23..23); + + assert_eq!( + generate_marked_text(&text, &ranges, true), + "one «ˇtwo» «threeˇ» «ˇfour» fiveˇ six" + ); + } +} diff --git a/third_party/gpui_support/gpui_util/src/time.rs b/third_party/gpui_support/gpui_util/src/time.rs new file mode 100644 index 0000000..365de6f --- /dev/null +++ b/third_party/gpui_support/gpui_util/src/time.rs @@ -0,0 +1,41 @@ +use std::time::Duration; + +pub fn duration_alt_display(duration: Duration) -> String { + if duration < Duration::from_secs(60) { + format!("{}s", duration.as_secs()) + } else { + duration_clock_format(duration) + } +} + +fn duration_clock_format(duration: Duration) -> String { + let hours = duration.as_secs() / 3600; + let minutes = (duration.as_secs() % 3600) / 60; + let seconds = duration.as_secs() % 60; + + if hours > 0 { + format!("{hours}:{minutes:02}:{seconds:02}") + } else if minutes > 0 { + format!("{minutes}:{seconds:02}") + } else { + format!("{seconds}") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_duration_to_clock_format() { + use duration_clock_format as f; + assert_eq!("0", f(Duration::from_secs(0))); + assert_eq!("59", f(Duration::from_secs(59))); + assert_eq!("1:00", f(Duration::from_secs(60))); + assert_eq!("10:00", f(Duration::from_secs(600))); + assert_eq!("1:00:00", f(Duration::from_secs(3600))); + assert_eq!("3:02:01", f(Duration::from_secs(3600 * 3 + 60 * 2 + 1))); + assert_eq!("23:59:59", f(Duration::from_secs(3600 * 24 - 1))); + assert_eq!("100:00:00", f(Duration::from_secs(3600 * 100))); + } +} diff --git a/third_party/gpui_support/gpui_util/src/util.rs b/third_party/gpui_support/gpui_util/src/util.rs new file mode 100644 index 0000000..3f1205f --- /dev/null +++ b/third_party/gpui_support/gpui_util/src/util.rs @@ -0,0 +1,1310 @@ +pub mod arc_cow; +pub mod archive; +pub mod command; +pub mod fs; +pub mod markdown; +pub mod paths; +pub mod redact; +pub mod rel_path; +pub mod schemars; +pub mod serde; +pub mod shell; +pub mod shell_env; +pub mod size; +#[cfg(any(test, feature = "test-support"))] +pub mod test; +pub mod time; + +use anyhow::{Context as _, Result}; +use futures::Future; +use itertools::Either; +use paths::PathExt; +use regex::Regex; +use std::path::PathBuf; +use std::sync::{LazyLock, OnceLock}; +use std::{ + borrow::Cow, + cmp::{self, Ordering}, + env, + ops::{AddAssign, Range, RangeInclusive}, + panic::Location, + pin::Pin, + task::{Context, Poll}, + time::Instant, +}; +use unicase::UniCase; + +pub use take_until::*; +#[cfg(any(test, feature = "test-support"))] +pub use util_macros::{line_endings, path, uri}; + +#[macro_export] +macro_rules! debug_panic { + ( $($fmt_arg:tt)* ) => { + if cfg!(debug_assertions) { + panic!( $($fmt_arg)* ); + } else { + let backtrace = std::backtrace::Backtrace::capture(); + log::error!("{}\n{:?}", format_args!($($fmt_arg)*), backtrace); + } + }; +} + +pub fn truncate(s: &str, max_chars: usize) -> &str { + match s.char_indices().nth(max_chars) { + None => s, + Some((idx, _)) => &s[..idx], + } +} + +/// Removes characters from the end of the string if its length is greater than `max_chars` and +/// appends "..." to the string. Returns string unchanged if its length is smaller than max_chars. +pub fn truncate_and_trailoff(s: &str, max_chars: usize) -> String { + debug_assert!(max_chars >= 5); + + // If the string's byte length is <= max_chars, walking the string can be skipped since the + // number of chars is <= the number of bytes. + if s.len() <= max_chars { + return s.to_string(); + } + let truncation_ix = s.char_indices().map(|(i, _)| i).nth(max_chars); + match truncation_ix { + Some(index) => s[..index].to_string() + "…", + _ => s.to_string(), + } +} + +/// Removes characters from the front of the string if its length is greater than `max_chars` and +/// prepends the string with "...". Returns string unchanged if its length is smaller than max_chars. +pub fn truncate_and_remove_front(s: &str, max_chars: usize) -> String { + debug_assert!(max_chars >= 5); + + // If the string's byte length is <= max_chars, walking the string can be skipped since the + // number of chars is <= the number of bytes. + if s.len() <= max_chars { + return s.to_string(); + } + let suffix_char_length = max_chars.saturating_sub(1); + let truncation_ix = s + .char_indices() + .map(|(i, _)| i) + .nth_back(suffix_char_length); + match truncation_ix { + Some(index) if index > 0 => "…".to_string() + &s[index..], + _ => s.to_string(), + } +} + +/// Takes only `max_lines` from the string and, if there were more than `max_lines-1`, appends a +/// a newline and "..." to the string, so that `max_lines` are returned. +/// Returns string unchanged if its length is smaller than max_lines. +pub fn truncate_lines_and_trailoff(s: &str, max_lines: usize) -> String { + let mut lines = s.lines().take(max_lines).collect::>(); + if lines.len() > max_lines - 1 { + lines.pop(); + lines.join("\n") + "\n…" + } else { + lines.join("\n") + } +} + +/// Truncates the string at a character boundary, such that the result is less than `max_bytes` in +/// length. +pub fn truncate_to_byte_limit(s: &str, max_bytes: usize) -> &str { + if s.len() < max_bytes { + return s; + } + + for i in (0..max_bytes).rev() { + if s.is_char_boundary(i) { + return &s[..i]; + } + } + + "" +} + +/// Takes a prefix of complete lines which fit within the byte limit. If the first line is longer +/// than the limit, truncates at a character boundary. +pub fn truncate_lines_to_byte_limit(s: &str, max_bytes: usize) -> &str { + if s.len() < max_bytes { + return s; + } + + for i in (0..max_bytes).rev() { + if s.is_char_boundary(i) && s.as_bytes()[i] == b'\n' { + // Since the i-th character is \n, valid to slice at i + 1. + return &s[..i + 1]; + } + } + + truncate_to_byte_limit(s, max_bytes) +} + +#[test] +fn test_truncate_lines_to_byte_limit() { + let text = "Line 1\nLine 2\nLine 3\nLine 4"; + + // Limit that includes all lines + assert_eq!(truncate_lines_to_byte_limit(text, 100), text); + + // Exactly the first line + assert_eq!(truncate_lines_to_byte_limit(text, 7), "Line 1\n"); + + // Limit between lines + assert_eq!(truncate_lines_to_byte_limit(text, 13), "Line 1\n"); + assert_eq!(truncate_lines_to_byte_limit(text, 20), "Line 1\nLine 2\n"); + + // Limit before first newline + assert_eq!(truncate_lines_to_byte_limit(text, 6), "Line "); + + // Test with non-ASCII characters + let text_utf8 = "Line 1\nLíne 2\nLine 3"; + assert_eq!( + truncate_lines_to_byte_limit(text_utf8, 15), + "Line 1\nLíne 2\n" + ); +} + +pub fn post_inc + AddAssign + Copy>(value: &mut T) -> T { + let prev = *value; + *value += T::from(1); + prev +} + +/// Extend a sorted vector with a sorted sequence of items, maintaining the vector's sort order and +/// enforcing a maximum length. This also de-duplicates items. Sort the items according to the given callback. Before calling this, +/// both `vec` and `new_items` should already be sorted according to the `cmp` comparator. +pub fn extend_sorted(vec: &mut Vec, new_items: I, limit: usize, mut cmp: F) +where + I: IntoIterator, + F: FnMut(&T, &T) -> Ordering, +{ + let mut start_index = 0; + for new_item in new_items { + if let Err(i) = vec[start_index..].binary_search_by(|m| cmp(m, &new_item)) { + let index = start_index + i; + if vec.len() < limit { + vec.insert(index, new_item); + } else if index < vec.len() { + vec.pop(); + vec.insert(index, new_item); + } + start_index = index; + } + } +} + +pub fn truncate_to_bottom_n_sorted_by(items: &mut Vec, limit: usize, compare: &F) +where + F: Fn(&T, &T) -> Ordering, +{ + if limit == 0 { + items.truncate(0); + } + if items.len() <= limit { + items.sort_by(compare); + return; + } + // When limit is near to items.len() it may be more efficient to sort the whole list and + // truncate, rather than always doing selection first as is done below. It's hard to analyze + // where the threshold for this should be since the quickselect style algorithm used by + // `select_nth_unstable_by` makes the prefix partially sorted, and so its work is not wasted - + // the expected number of comparisons needed by `sort_by` is less than it is for some arbitrary + // unsorted input. + items.select_nth_unstable_by(limit, compare); + items.truncate(limit); + items.sort_by(compare); +} + +/// Prevents execution of the application with root privileges on Unix systems. +/// +/// This function checks if the current process is running with root privileges +/// and terminates the program with an error message unless explicitly allowed via the +/// `ZED_ALLOW_ROOT` environment variable. +#[cfg(unix)] +pub fn prevent_root_execution() { + let is_root = nix::unistd::geteuid().is_root(); + let allow_root = std::env::var("ZED_ALLOW_ROOT").is_ok_and(|val| val == "true"); + + if is_root && !allow_root { + eprintln!( + "\ +Error: Running Zed as root or via sudo is unsupported. + Doing so (even once) may subtly break things for all subsequent non-root usage of Zed. + It is untested and not recommended, don't complain when things break. + If you wish to proceed anyways, set `ZED_ALLOW_ROOT=true` in your environment." + ); + std::process::exit(1); + } +} + +#[cfg(unix)] +fn load_shell_from_passwd() -> Result<()> { + let buflen = match unsafe { libc::sysconf(libc::_SC_GETPW_R_SIZE_MAX) } { + n if n < 0 => 1024, + n => n as usize, + }; + let mut buffer = Vec::with_capacity(buflen); + + let mut pwd: std::mem::MaybeUninit = std::mem::MaybeUninit::uninit(); + let mut result: *mut libc::passwd = std::ptr::null_mut(); + + let uid = unsafe { libc::getuid() }; + let status = unsafe { + libc::getpwuid_r( + uid, + pwd.as_mut_ptr(), + buffer.as_mut_ptr() as *mut libc::c_char, + buflen, + &mut result, + ) + }; + anyhow::ensure!(!result.is_null(), "passwd entry for uid {} not found", uid); + + // SAFETY: If `getpwuid_r` doesn't error, we have the entry here. + let entry = unsafe { pwd.assume_init() }; + + anyhow::ensure!( + status == 0, + "call to getpwuid_r failed. uid: {}, status: {}", + uid, + status + ); + anyhow::ensure!( + entry.pw_uid == uid, + "passwd entry has different uid ({}) than getuid ({}) returned", + entry.pw_uid, + uid, + ); + + let shell = unsafe { std::ffi::CStr::from_ptr(entry.pw_shell).to_str().unwrap() }; + let should_set_shell = env::var("SHELL").map_or(true, |shell_env| { + shell_env != shell && !std::path::Path::new(&shell_env).exists() + }); + + if should_set_shell { + log::info!( + "updating SHELL environment variable to value from passwd entry: {:?}", + shell, + ); + unsafe { env::set_var("SHELL", shell) }; + } + + Ok(()) +} + +/// Returns a shell escaped path for the current zed executable +pub fn get_shell_safe_zed_path() -> anyhow::Result { + let zed_path = + std::env::current_exe().context("Failed to determine current zed executable path.")?; + + zed_path + .try_shell_safe() + .context("Failed to shell-escape Zed executable path.") +} + +/// Returns a path for the zed cli executable, this function +/// should be called from the zed executable, not zed-cli. +pub fn get_zed_cli_path() -> Result { + let zed_path = + std::env::current_exe().context("Failed to determine current zed executable path.")?; + let parent = zed_path + .parent() + .context("Failed to determine parent directory of zed executable path.")?; + + let possible_locations: &[&str] = if cfg!(target_os = "macos") { + // On macOS, the zed executable and zed-cli are inside the app bundle, + // so here ./cli is for both installed and development builds. + &["./cli"] + } else if cfg!(target_os = "windows") { + // bin/zed.exe is for installed builds, ./cli.exe is for development builds. + &["bin/zed.exe", "./cli.exe"] + } else if cfg!(target_os = "linux") || cfg!(target_os = "freebsd") { + // bin is the standard, ./cli is for the target directory in development builds. + &["../bin/zed", "./cli"] + } else { + anyhow::bail!("unsupported platform for determining zed-cli path"); + }; + + possible_locations + .iter() + .find_map(|p| { + parent + .join(p) + .canonicalize() + .ok() + .filter(|p| p != &zed_path) + }) + .with_context(|| { + format!( + "could not find zed-cli from any of: {}", + possible_locations.join(", ") + ) + }) +} + +#[cfg(unix)] +pub async fn load_login_shell_environment() -> Result<()> { + load_shell_from_passwd().log_err(); + + // If possible, we want to `cd` in the user's `$HOME` to trigger programs + // such as direnv, asdf, mise, ... to adjust the PATH. These tools often hook + // into shell's `cd` command (and hooks) to manipulate env. + // We do this so that we get the env a user would have when spawning a shell + // in home directory. + for (name, value) in shell_env::capture(get_system_shell(), &[], paths::home_dir()).await? { + unsafe { env::set_var(&name, &value) }; + } + + log::info!( + "set environment variables from shell:{}, path:{}", + std::env::var("SHELL").unwrap_or_default(), + std::env::var("PATH").unwrap_or_default(), + ); + + Ok(()) +} + +/// Configures the process to start a new session, to prevent interactive shells from taking control +/// of the terminal. +/// +/// For more details: +pub fn set_pre_exec_to_start_new_session( + command: &mut std::process::Command, +) -> &mut std::process::Command { + // safety: code in pre_exec should be signal safe. + // https://man7.org/linux/man-pages/man7/signal-safety.7.html + #[cfg(not(target_os = "windows"))] + unsafe { + use std::os::unix::process::CommandExt; + command.pre_exec(|| { + libc::setsid(); + Ok(()) + }); + }; + command +} + +pub fn merge_json_lenient_value_into( + source: serde_json_lenient::Value, + target: &mut serde_json_lenient::Value, +) { + match (source, target) { + (serde_json_lenient::Value::Object(source), serde_json_lenient::Value::Object(target)) => { + for (key, value) in source { + if let Some(target) = target.get_mut(&key) { + merge_json_lenient_value_into(value, target); + } else { + target.insert(key, value); + } + } + } + + (serde_json_lenient::Value::Array(source), serde_json_lenient::Value::Array(target)) => { + for value in source { + target.push(value); + } + } + + (source, target) => *target = source, + } +} + +pub fn merge_json_value_into(source: serde_json::Value, target: &mut serde_json::Value) { + use serde_json::Value; + + match (source, target) { + (Value::Object(source), Value::Object(target)) => { + for (key, value) in source { + if let Some(target) = target.get_mut(&key) { + merge_json_value_into(value, target); + } else { + target.insert(key, value); + } + } + } + + (Value::Array(source), Value::Array(target)) => { + for value in source { + target.push(value); + } + } + + (source, target) => *target = source, + } +} + +pub fn merge_non_null_json_value_into(source: serde_json::Value, target: &mut serde_json::Value) { + use serde_json::Value; + if let Value::Object(source_object) = source { + let target_object = if let Value::Object(target) = target { + target + } else { + *target = Value::Object(Default::default()); + target.as_object_mut().unwrap() + }; + for (key, value) in source_object { + if let Some(target) = target_object.get_mut(&key) { + merge_non_null_json_value_into(value, target); + } else if !value.is_null() { + target_object.insert(key, value); + } + } + } else if !source.is_null() { + *target = source + } +} + +pub fn measure(label: &str, f: impl FnOnce() -> R) -> R { + static ZED_MEASUREMENTS: OnceLock = OnceLock::new(); + let zed_measurements = ZED_MEASUREMENTS.get_or_init(|| { + env::var("ZED_MEASUREMENTS") + .map(|measurements| measurements == "1" || measurements == "true") + .unwrap_or(false) + }); + + if *zed_measurements { + let start = Instant::now(); + let result = f(); + let elapsed = start.elapsed(); + eprintln!("{}: {:?}", label, elapsed); + result + } else { + f() + } +} + +pub fn expanded_and_wrapped_usize_range( + range: Range, + additional_before: usize, + additional_after: usize, + wrap_length: usize, +) -> impl Iterator { + let start_wraps = range.start < additional_before; + let end_wraps = wrap_length < range.end + additional_after; + if start_wraps && end_wraps { + Either::Left(0..wrap_length) + } else if start_wraps { + let wrapped_start = (range.start + wrap_length).saturating_sub(additional_before); + if wrapped_start <= range.end { + Either::Left(0..wrap_length) + } else { + Either::Right((0..range.end + additional_after).chain(wrapped_start..wrap_length)) + } + } else if end_wraps { + let wrapped_end = range.end + additional_after - wrap_length; + if range.start <= wrapped_end { + Either::Left(0..wrap_length) + } else { + Either::Right((0..wrapped_end).chain(range.start - additional_before..wrap_length)) + } + } else { + Either::Left((range.start - additional_before)..(range.end + additional_after)) + } +} + +/// Yields `[i, i + 1, i - 1, i + 2, ..]`, each modulo `wrap_length` and bounded by +/// `additional_before` and `additional_after`. If the wrapping causes overlap, duplicates are not +/// emitted. If wrap_length is 0, nothing is yielded. +pub fn wrapped_usize_outward_from( + start: usize, + additional_before: usize, + additional_after: usize, + wrap_length: usize, +) -> impl Iterator { + let mut count = 0; + let mut after_offset = 1; + let mut before_offset = 1; + + std::iter::from_fn(move || { + count += 1; + if count > wrap_length { + None + } else if count == 1 { + Some(start % wrap_length) + } else if after_offset <= additional_after && after_offset <= before_offset { + let value = (start + after_offset) % wrap_length; + after_offset += 1; + Some(value) + } else if before_offset <= additional_before { + let value = (start + wrap_length - before_offset) % wrap_length; + before_offset += 1; + Some(value) + } else if after_offset <= additional_after { + let value = (start + after_offset) % wrap_length; + after_offset += 1; + Some(value) + } else { + None + } + }) +} + +pub trait ResultExt { + type Ok; + + fn log_err(self) -> Option; + /// Assert that this result should never be an error in development or tests. + fn debug_assert_ok(self, reason: &str) -> Self; + fn warn_on_err(self) -> Option; + fn log_with_level(self, level: log::Level) -> Option; + fn anyhow(self) -> anyhow::Result + where + E: Into; +} + +impl ResultExt for Result +where + E: std::fmt::Debug, +{ + type Ok = T; + + #[track_caller] + fn log_err(self) -> Option { + self.log_with_level(log::Level::Error) + } + + #[track_caller] + fn debug_assert_ok(self, reason: &str) -> Self { + if let Err(error) = &self { + debug_panic!("{reason} - {error:?}"); + } + self + } + + #[track_caller] + fn warn_on_err(self) -> Option { + self.log_with_level(log::Level::Warn) + } + + #[track_caller] + fn log_with_level(self, level: log::Level) -> Option { + match self { + Ok(value) => Some(value), + Err(error) => { + log_error_with_caller(*Location::caller(), error, level); + None + } + } + } + + fn anyhow(self) -> anyhow::Result + where + E: Into, + { + self.map_err(Into::into) + } +} + +fn log_error_with_caller(caller: core::panic::Location<'_>, error: E, level: log::Level) +where + E: std::fmt::Debug, +{ + #[cfg(not(target_os = "windows"))] + let file = caller.file(); + #[cfg(target_os = "windows")] + let file = caller.file().replace('\\', "/"); + // In this codebase all crates reside in a `crates` directory, + // so discard the prefix up to that segment to find the crate name + let target = file + .split_once("crates/") + .and_then(|(_, s)| s.split_once("/src/")); + + let module_path = target.map(|(krate, module)| { + krate.to_owned() + "::" + &module.trim_end_matches(".rs").replace('/', "::") + }); + log::logger().log( + &log::Record::builder() + .target(target.map_or("", |(krate, _)| krate)) + .module_path(module_path.as_deref()) + .args(format_args!("{:?}", error)) + .file(Some(caller.file())) + .line(Some(caller.line())) + .level(level) + .build(), + ); +} + +pub fn log_err(error: &E) { + log_error_with_caller(*Location::caller(), error, log::Level::Warn); +} + +pub trait TryFutureExt { + fn log_err(self) -> LogErrorFuture + where + Self: Sized; + + fn log_tracked_err(self, location: core::panic::Location<'static>) -> LogErrorFuture + where + Self: Sized; + + fn warn_on_err(self) -> LogErrorFuture + where + Self: Sized; + fn unwrap(self) -> UnwrapFuture + where + Self: Sized; +} + +impl TryFutureExt for F +where + F: Future>, + E: std::fmt::Debug, +{ + #[track_caller] + fn log_err(self) -> LogErrorFuture + where + Self: Sized, + { + let location = Location::caller(); + LogErrorFuture(self, log::Level::Error, *location) + } + + fn log_tracked_err(self, location: core::panic::Location<'static>) -> LogErrorFuture + where + Self: Sized, + { + LogErrorFuture(self, log::Level::Error, location) + } + + #[track_caller] + fn warn_on_err(self) -> LogErrorFuture + where + Self: Sized, + { + let location = Location::caller(); + LogErrorFuture(self, log::Level::Warn, *location) + } + + fn unwrap(self) -> UnwrapFuture + where + Self: Sized, + { + UnwrapFuture(self) + } +} + +#[must_use] +pub struct LogErrorFuture(F, log::Level, core::panic::Location<'static>); + +impl Future for LogErrorFuture +where + F: Future>, + E: std::fmt::Debug, +{ + type Output = Option; + + fn poll(self: Pin<&mut Self>, cx: &mut Context) -> Poll { + let level = self.1; + let location = self.2; + let inner = unsafe { Pin::new_unchecked(&mut self.get_unchecked_mut().0) }; + match inner.poll(cx) { + Poll::Ready(output) => Poll::Ready(match output { + Ok(output) => Some(output), + Err(error) => { + log_error_with_caller(location, error, level); + None + } + }), + Poll::Pending => Poll::Pending, + } + } +} + +pub struct UnwrapFuture(F); + +impl Future for UnwrapFuture +where + F: Future>, + E: std::fmt::Debug, +{ + type Output = T; + + fn poll(self: Pin<&mut Self>, cx: &mut Context) -> Poll { + let inner = unsafe { Pin::new_unchecked(&mut self.get_unchecked_mut().0) }; + match inner.poll(cx) { + Poll::Ready(result) => Poll::Ready(result.unwrap()), + Poll::Pending => Poll::Pending, + } + } +} + +pub struct Deferred(Option); + +impl Deferred { + /// Drop without running the deferred function. + pub fn abort(mut self) { + self.0.take(); + } +} + +impl Drop for Deferred { + fn drop(&mut self) { + if let Some(f) = self.0.take() { + f() + } + } +} + +/// Run the given function when the returned value is dropped (unless it's cancelled). +#[must_use] +pub fn defer(f: F) -> Deferred { + Deferred(Some(f)) +} + +#[cfg(any(test, feature = "test-support"))] +mod rng { + use rand::prelude::*; + + pub struct RandomCharIter { + rng: T, + simple_text: bool, + } + + impl RandomCharIter { + pub fn new(rng: T) -> Self { + Self { + rng, + simple_text: std::env::var("SIMPLE_TEXT").is_ok_and(|v| !v.is_empty()), + } + } + + pub fn with_simple_text(mut self) -> Self { + self.simple_text = true; + self + } + } + + impl Iterator for RandomCharIter { + type Item = char; + + fn next(&mut self) -> Option { + if self.simple_text { + return if self.rng.random_range(0..100) < 5 { + Some('\n') + } else { + Some(self.rng.random_range(b'a'..b'z' + 1).into()) + }; + } + + match self.rng.random_range(0..100) { + // whitespace + 0..=19 => [' ', '\n', '\r', '\t'].choose(&mut self.rng).copied(), + // two-byte greek letters + 20..=32 => char::from_u32(self.rng.random_range(('α' as u32)..('ω' as u32 + 1))), + // // three-byte characters + 33..=45 => ['✋', '✅', '❌', '❎', '⭐'] + .choose(&mut self.rng) + .copied(), + // // four-byte characters + 46..=58 => ['🍐', '🏀', '🍗', '🎉'].choose(&mut self.rng).copied(), + // ascii letters + _ => Some(self.rng.random_range(b'a'..b'z' + 1).into()), + } + } + } +} +#[cfg(any(test, feature = "test-support"))] +pub use rng::RandomCharIter; + +/// Get an embedded file as a string. +pub fn asset_str(path: &str) -> Cow<'static, str> { + match A::get(path).expect(path).data { + Cow::Borrowed(bytes) => Cow::Borrowed(std::str::from_utf8(bytes).unwrap()), + Cow::Owned(bytes) => Cow::Owned(String::from_utf8(bytes).unwrap()), + } +} + +/// Expands to an immediately-invoked function expression. Good for using the ? operator +/// in functions which do not return an Option or Result. +/// +/// Accepts a normal block, an async block, or an async move block. +#[macro_export] +macro_rules! maybe { + ($block:block) => { + (|| $block)() + }; + (async $block:block) => { + (async || $block)() + }; + (async move $block:block) => { + (async move || $block)() + }; +} + +pub trait RangeExt { + fn sorted(&self) -> Self; + fn to_inclusive(&self) -> RangeInclusive; + fn overlaps(&self, other: &Range) -> bool; + fn contains_inclusive(&self, other: &Range) -> bool; +} + +impl RangeExt for Range { + fn sorted(&self) -> Self { + cmp::min(&self.start, &self.end).clone()..cmp::max(&self.start, &self.end).clone() + } + + fn to_inclusive(&self) -> RangeInclusive { + self.start.clone()..=self.end.clone() + } + + fn overlaps(&self, other: &Range) -> bool { + self.start < other.end && other.start < self.end + } + + fn contains_inclusive(&self, other: &Range) -> bool { + self.start <= other.start && other.end <= self.end + } +} + +impl RangeExt for RangeInclusive { + fn sorted(&self) -> Self { + cmp::min(self.start(), self.end()).clone()..=cmp::max(self.start(), self.end()).clone() + } + + fn to_inclusive(&self) -> RangeInclusive { + self.clone() + } + + fn overlaps(&self, other: &Range) -> bool { + self.start() < &other.end && &other.start <= self.end() + } + + fn contains_inclusive(&self, other: &Range) -> bool { + self.start() <= &other.start && &other.end <= self.end() + } +} + +/// A way to sort strings with starting numbers numerically first, falling back to alphanumeric one, +/// case-insensitive. +/// +/// This is useful for turning regular alphanumerically sorted sequences as `1-abc, 10, 11-def, .., 2, 21-abc` +/// into `1-abc, 2, 10, 11-def, .., 21-abc` +#[derive(Debug, PartialEq, Eq)] +pub struct NumericPrefixWithSuffix<'a>(Option, &'a str); + +impl<'a> NumericPrefixWithSuffix<'a> { + pub fn from_numeric_prefixed_str(str: &'a str) -> Self { + let i = str.chars().take_while(|c| c.is_ascii_digit()).count(); + let (prefix, remainder) = str.split_at(i); + + let prefix = prefix.parse().ok(); + Self(prefix, remainder) + } +} + +/// When dealing with equality, we need to consider the case of the strings to achieve strict equality +/// to handle cases like "a" < "A" instead of "a" == "A". +impl Ord for NumericPrefixWithSuffix<'_> { + fn cmp(&self, other: &Self) -> Ordering { + match (self.0, other.0) { + (None, None) => UniCase::new(self.1) + .cmp(&UniCase::new(other.1)) + .then_with(|| self.1.cmp(other.1).reverse()), + (None, Some(_)) => Ordering::Greater, + (Some(_), None) => Ordering::Less, + (Some(a), Some(b)) => a.cmp(&b).then_with(|| { + UniCase::new(self.1) + .cmp(&UniCase::new(other.1)) + .then_with(|| self.1.cmp(other.1).reverse()) + }), + } + } +} + +impl PartialOrd for NumericPrefixWithSuffix<'_> { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +/// Capitalizes the first character of a string. +/// +/// This function takes a string slice as input and returns a new `String` with the first character +/// capitalized. +/// +/// # Examples +/// +/// ``` +/// use util::capitalize; +/// +/// assert_eq!(capitalize("hello"), "Hello"); +/// assert_eq!(capitalize("WORLD"), "WORLD"); +/// assert_eq!(capitalize(""), ""); +/// ``` +pub fn capitalize(str: &str) -> String { + let mut chars = str.chars(); + match chars.next() { + None => String::new(), + Some(first_char) => first_char.to_uppercase().collect::() + chars.as_str(), + } +} + +fn emoji_regex() -> &'static Regex { + static EMOJI_REGEX: LazyLock = + LazyLock::new(|| Regex::new("(\\p{Emoji}|\u{200D})").unwrap()); + &EMOJI_REGEX +} + +/// Returns true if the given string consists of emojis only. +/// E.g. "👨‍👩‍👧‍👧👋" will return true, but "👋!" will return false. +pub fn word_consists_of_emojis(s: &str) -> bool { + let mut prev_end = 0; + for capture in emoji_regex().find_iter(s) { + if capture.start() != prev_end { + return false; + } + prev_end = capture.end(); + } + prev_end == s.len() +} + +/// Similar to `str::split`, but also provides byte-offset ranges of the results. Unlike +/// `str::split`, this is not generic on pattern types and does not return an `Iterator`. +pub fn split_str_with_ranges(s: &str, pat: impl Fn(char) -> bool) -> Vec<(Range, &str)> { + let mut result = Vec::new(); + let mut start = 0; + + for (i, ch) in s.char_indices() { + if pat(ch) { + if i > start { + result.push((start..i, &s[start..i])); + } + start = i + ch.len_utf8(); + } + } + + if s.len() > start { + result.push((start..s.len(), &s[start..s.len()])); + } + + result +} + +pub fn default() -> D { + Default::default() +} + +pub use self::shell::{ + get_default_system_shell, get_default_system_shell_preferring_bash, get_system_shell, +}; + +#[derive(Debug)] +pub enum ConnectionResult { + Timeout, + ConnectionReset, + Result(anyhow::Result), +} + +impl ConnectionResult { + pub fn into_response(self) -> anyhow::Result { + match self { + ConnectionResult::Timeout => anyhow::bail!("Request timed out"), + ConnectionResult::ConnectionReset => anyhow::bail!("Server reset the connection"), + ConnectionResult::Result(r) => r, + } + } +} + +impl From> for ConnectionResult { + fn from(result: anyhow::Result) -> Self { + ConnectionResult::Result(result) + } +} + +#[track_caller] +pub fn some_or_debug_panic(option: Option) -> Option { + #[cfg(debug_assertions)] + if option.is_none() { + panic!("Unexpected None"); + } + option +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_extend_sorted() { + let mut vec = vec![]; + + extend_sorted(&mut vec, vec![21, 17, 13, 8, 1, 0], 5, |a, b| b.cmp(a)); + assert_eq!(vec, &[21, 17, 13, 8, 1]); + + extend_sorted(&mut vec, vec![101, 19, 17, 8, 2], 8, |a, b| b.cmp(a)); + assert_eq!(vec, &[101, 21, 19, 17, 13, 8, 2, 1]); + + extend_sorted(&mut vec, vec![1000, 19, 17, 9, 5], 8, |a, b| b.cmp(a)); + assert_eq!(vec, &[1000, 101, 21, 19, 17, 13, 9, 8]); + } + + #[test] + fn test_truncate_to_bottom_n_sorted_by() { + let mut vec: Vec = vec![5, 2, 3, 4, 1]; + truncate_to_bottom_n_sorted_by(&mut vec, 10, &u32::cmp); + assert_eq!(vec, &[1, 2, 3, 4, 5]); + + vec = vec![5, 2, 3, 4, 1]; + truncate_to_bottom_n_sorted_by(&mut vec, 5, &u32::cmp); + assert_eq!(vec, &[1, 2, 3, 4, 5]); + + vec = vec![5, 2, 3, 4, 1]; + truncate_to_bottom_n_sorted_by(&mut vec, 4, &u32::cmp); + assert_eq!(vec, &[1, 2, 3, 4]); + + vec = vec![5, 2, 3, 4, 1]; + truncate_to_bottom_n_sorted_by(&mut vec, 1, &u32::cmp); + assert_eq!(vec, &[1]); + + vec = vec![5, 2, 3, 4, 1]; + truncate_to_bottom_n_sorted_by(&mut vec, 0, &u32::cmp); + assert!(vec.is_empty()); + } + + #[test] + fn test_iife() { + fn option_returning_function() -> Option<()> { + None + } + + let foo = maybe!({ + option_returning_function()?; + Some(()) + }); + + assert_eq!(foo, None); + } + + #[test] + fn test_truncate_and_trailoff() { + assert_eq!(truncate_and_trailoff("", 5), ""); + assert_eq!(truncate_and_trailoff("aaaaaa", 7), "aaaaaa"); + assert_eq!(truncate_and_trailoff("aaaaaa", 6), "aaaaaa"); + assert_eq!(truncate_and_trailoff("aaaaaa", 5), "aaaaa…"); + assert_eq!(truncate_and_trailoff("èèèèèè", 7), "èèèèèè"); + assert_eq!(truncate_and_trailoff("èèèèèè", 6), "èèèèèè"); + assert_eq!(truncate_and_trailoff("èèèèèè", 5), "èèèèè…"); + } + + #[test] + fn test_truncate_and_remove_front() { + assert_eq!(truncate_and_remove_front("", 5), ""); + assert_eq!(truncate_and_remove_front("aaaaaa", 7), "aaaaaa"); + assert_eq!(truncate_and_remove_front("aaaaaa", 6), "aaaaaa"); + assert_eq!(truncate_and_remove_front("aaaaaa", 5), "…aaaaa"); + assert_eq!(truncate_and_remove_front("èèèèèè", 7), "èèèèèè"); + assert_eq!(truncate_and_remove_front("èèèèèè", 6), "èèèèèè"); + assert_eq!(truncate_and_remove_front("èèèèèè", 5), "…èèèèè"); + } + + #[test] + fn test_numeric_prefix_str_method() { + let target = "1a"; + assert_eq!( + NumericPrefixWithSuffix::from_numeric_prefixed_str(target), + NumericPrefixWithSuffix(Some(1), "a") + ); + + let target = "12ab"; + assert_eq!( + NumericPrefixWithSuffix::from_numeric_prefixed_str(target), + NumericPrefixWithSuffix(Some(12), "ab") + ); + + let target = "12_ab"; + assert_eq!( + NumericPrefixWithSuffix::from_numeric_prefixed_str(target), + NumericPrefixWithSuffix(Some(12), "_ab") + ); + + let target = "1_2ab"; + assert_eq!( + NumericPrefixWithSuffix::from_numeric_prefixed_str(target), + NumericPrefixWithSuffix(Some(1), "_2ab") + ); + + let target = "1.2"; + assert_eq!( + NumericPrefixWithSuffix::from_numeric_prefixed_str(target), + NumericPrefixWithSuffix(Some(1), ".2") + ); + + let target = "1.2_a"; + assert_eq!( + NumericPrefixWithSuffix::from_numeric_prefixed_str(target), + NumericPrefixWithSuffix(Some(1), ".2_a") + ); + + let target = "12.2_a"; + assert_eq!( + NumericPrefixWithSuffix::from_numeric_prefixed_str(target), + NumericPrefixWithSuffix(Some(12), ".2_a") + ); + + let target = "12a.2_a"; + assert_eq!( + NumericPrefixWithSuffix::from_numeric_prefixed_str(target), + NumericPrefixWithSuffix(Some(12), "a.2_a") + ); + } + + #[test] + fn test_numeric_prefix_with_suffix() { + let mut sorted = vec!["1-abc", "10", "11def", "2", "21-abc"]; + sorted.sort_by_key(|s| NumericPrefixWithSuffix::from_numeric_prefixed_str(s)); + assert_eq!(sorted, ["1-abc", "2", "10", "11def", "21-abc"]); + + for numeric_prefix_less in ["numeric_prefix_less", "aaa", "~™£"] { + assert_eq!( + NumericPrefixWithSuffix::from_numeric_prefixed_str(numeric_prefix_less), + NumericPrefixWithSuffix(None, numeric_prefix_less), + "String without numeric prefix `{numeric_prefix_less}` should not be converted into NumericPrefixWithSuffix" + ) + } + } + + #[test] + fn test_word_consists_of_emojis() { + let words_to_test = vec![ + ("👨‍👩‍👧‍👧👋🥒", true), + ("👋", true), + ("!👋", false), + ("👋!", false), + ("👋 ", false), + (" 👋", false), + ("Test", false), + ]; + + for (text, expected_result) in words_to_test { + assert_eq!(word_consists_of_emojis(text), expected_result); + } + } + + #[test] + fn test_truncate_lines_and_trailoff() { + let text = r#"Line 1 +Line 2 +Line 3"#; + + assert_eq!( + truncate_lines_and_trailoff(text, 2), + r#"Line 1 +…"# + ); + + assert_eq!( + truncate_lines_and_trailoff(text, 3), + r#"Line 1 +Line 2 +…"# + ); + + assert_eq!( + truncate_lines_and_trailoff(text, 4), + r#"Line 1 +Line 2 +Line 3"# + ); + } + + #[test] + fn test_expanded_and_wrapped_usize_range() { + // Neither wrap + assert_eq!( + expanded_and_wrapped_usize_range(2..4, 1, 1, 8).collect::>(), + (1..5).collect::>() + ); + // Start wraps + assert_eq!( + expanded_and_wrapped_usize_range(2..4, 3, 1, 8).collect::>(), + ((0..5).chain(7..8)).collect::>() + ); + // Start wraps all the way around + assert_eq!( + expanded_and_wrapped_usize_range(2..4, 5, 1, 8).collect::>(), + (0..8).collect::>() + ); + // Start wraps all the way around and past 0 + assert_eq!( + expanded_and_wrapped_usize_range(2..4, 10, 1, 8).collect::>(), + (0..8).collect::>() + ); + // End wraps + assert_eq!( + expanded_and_wrapped_usize_range(3..5, 1, 4, 8).collect::>(), + (0..1).chain(2..8).collect::>() + ); + // End wraps all the way around + assert_eq!( + expanded_and_wrapped_usize_range(3..5, 1, 5, 8).collect::>(), + (0..8).collect::>() + ); + // End wraps all the way around and past the end + assert_eq!( + expanded_and_wrapped_usize_range(3..5, 1, 10, 8).collect::>(), + (0..8).collect::>() + ); + // Both start and end wrap + assert_eq!( + expanded_and_wrapped_usize_range(3..5, 4, 4, 8).collect::>(), + (0..8).collect::>() + ); + } + + #[test] + fn test_wrapped_usize_outward_from() { + // No wrapping + assert_eq!( + wrapped_usize_outward_from(4, 2, 2, 10).collect::>(), + vec![4, 5, 3, 6, 2] + ); + // Wrapping at end + assert_eq!( + wrapped_usize_outward_from(8, 2, 3, 10).collect::>(), + vec![8, 9, 7, 0, 6, 1] + ); + // Wrapping at start + assert_eq!( + wrapped_usize_outward_from(1, 3, 2, 10).collect::>(), + vec![1, 2, 0, 3, 9, 8] + ); + // All values wrap around + assert_eq!( + wrapped_usize_outward_from(5, 10, 10, 8).collect::>(), + vec![5, 6, 4, 7, 3, 0, 2, 1] + ); + // None before / after + assert_eq!( + wrapped_usize_outward_from(3, 0, 0, 8).collect::>(), + vec![3] + ); + // Starting point already wrapped + assert_eq!( + wrapped_usize_outward_from(15, 2, 2, 10).collect::>(), + vec![5, 6, 4, 7, 3] + ); + // wrap_length of 0 + assert_eq!( + wrapped_usize_outward_from(4, 2, 2, 0).collect::>(), + Vec::::new() + ); + } + + #[test] + fn test_split_with_ranges() { + let input = "hi"; + let result = split_str_with_ranges(input, |c| c == ' '); + + assert_eq!(result.len(), 1); + assert_eq!(result[0], (0..2, "hi")); + + let input = "héllo🦀world"; + let result = split_str_with_ranges(input, |c| c == '🦀'); + + assert_eq!(result.len(), 2); + assert_eq!(result[0], (0..6, "héllo")); // 'é' is 2 bytes + assert_eq!(result[1], (10..15, "world")); // '🦀' is 4 bytes + } +} diff --git a/third_party/gpui_support/zed-sum-tree/Cargo.toml b/third_party/gpui_support/zed-sum-tree/Cargo.toml new file mode 100644 index 0000000..9076a79 --- /dev/null +++ b/third_party/gpui_support/zed-sum-tree/Cargo.toml @@ -0,0 +1,69 @@ +# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO +# +# When uploading crates to the registry Cargo will automatically +# "normalize" Cargo.toml files for maximal compatibility +# with all versions of Cargo and also rewrite `path` dependencies +# to registry (e.g., crates.io) dependencies. +# +# Local copy keeps the registry manifest shape and narrows log features so +# GPUI and Servo can share the same workspace dependency graph. + +[package] +edition = "2024" +name = "zed-sum-tree" +version = "0.2.0" +build = false +publish = true +autolib = false +autobins = false +autoexamples = false +autotests = false +autobenches = false +description = "A sum tree data structure, a concurrency-friendly B-tree" +readme = false +license = "Apache-2.0" +resolver = "2" + +[lib] +name = "zed_sum_tree" +path = "src/sum_tree.rs" +doctest = false + +[dependencies.arrayvec] +version = "0.7.1" + +[dependencies.log] +version = "0.4.16" + +[dependencies.rayon] +version = "1.8" + +[dependencies.workspace-hack] +version = "0.1.0" + +[dev-dependencies.ctor] +version = "0.4.0" + +[dev-dependencies.rand] +version = "0.9" + +[lints.clippy] +dbg_macro = "deny" +declare_interior_mutable_const = "deny" +disallowed_methods = "deny" +large_enum_variant = "allow" +let_underscore_future = "allow" +nonminimal_bool = "allow" +redundant_clone = "deny" +single_range_in_vec_init = "allow" +todo = "deny" +too_many_arguments = "allow" +type_complexity = "allow" + +[lints.clippy.style] +level = "allow" +priority = -1 + +[lints.rust.unexpected_cfgs] +level = "allow" +priority = 0 diff --git a/third_party/gpui_support/zed-sum-tree/LICENSE-APACHE b/third_party/gpui_support/zed-sum-tree/LICENSE-APACHE new file mode 100644 index 0000000..461a0fe --- /dev/null +++ b/third_party/gpui_support/zed-sum-tree/LICENSE-APACHE @@ -0,0 +1,222 @@ +Copyright 2022 - 2025 Zed Industries, Inc. + + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + + http://www.apache.org/licenses/LICENSE-2.0 + + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + + + +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + + 1. Definitions. + + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + + END OF TERMS AND CONDITIONS diff --git a/third_party/gpui_support/zed-sum-tree/src/cursor.rs b/third_party/gpui_support/zed-sum-tree/src/cursor.rs new file mode 100644 index 0000000..6df1d3d --- /dev/null +++ b/third_party/gpui_support/zed-sum-tree/src/cursor.rs @@ -0,0 +1,817 @@ +use super::*; +use arrayvec::ArrayVec; +use std::{cmp::Ordering, mem, sync::Arc}; + +#[derive(Clone)] +struct StackEntry<'a, T: Item, D> { + tree: &'a SumTree, + index: u32, + position: D, +} + +impl<'a, T: Item, D> StackEntry<'a, T, D> { + #[inline] + fn index(&self) -> usize { + self.index as usize + } +} + +impl fmt::Debug for StackEntry<'_, T, D> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("StackEntry") + .field("index", &self.index) + .field("position", &self.position) + .finish() + } +} + +#[derive(Clone)] +pub struct Cursor<'a, 'b, T: Item, D> { + tree: &'a SumTree, + stack: ArrayVec, 16>, + position: D, + did_seek: bool, + at_end: bool, + cx: ::Context<'b>, +} + +impl fmt::Debug for Cursor<'_, '_, T, D> +where + T::Summary: fmt::Debug, +{ + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Cursor") + .field("tree", &self.tree) + .field("stack", &self.stack) + .field("position", &self.position) + .field("did_seek", &self.did_seek) + .field("at_end", &self.at_end) + .finish() + } +} + +pub struct Iter<'a, T: Item> { + tree: &'a SumTree, + stack: ArrayVec, 16>, +} + +impl<'a, 'b, T, D> Cursor<'a, 'b, T, D> +where + T: Item, + D: Dimension<'a, T::Summary>, +{ + pub fn new(tree: &'a SumTree, cx: ::Context<'b>) -> Self { + Self { + tree, + stack: ArrayVec::new(), + position: D::zero(cx), + did_seek: false, + at_end: tree.is_empty(), + cx, + } + } + + fn reset(&mut self) { + self.did_seek = false; + self.at_end = self.tree.is_empty(); + self.stack.truncate(0); + self.position = D::zero(self.cx); + } + + pub fn start(&self) -> &D { + &self.position + } + + #[track_caller] + pub fn end(&self) -> D { + if let Some(item_summary) = self.item_summary() { + let mut end = self.start().clone(); + end.add_summary(item_summary, self.cx); + end + } else { + self.start().clone() + } + } + + /// Item is None, when the list is empty, or this cursor is at the end of the list. + #[track_caller] + pub fn item(&self) -> Option<&'a T> { + self.assert_did_seek(); + if let Some(entry) = self.stack.last() { + match *entry.tree.0 { + Node::Leaf { ref items, .. } => { + if entry.index() == items.len() { + None + } else { + Some(&items[entry.index()]) + } + } + _ => unreachable!(), + } + } else { + None + } + } + + #[track_caller] + pub fn item_summary(&self) -> Option<&'a T::Summary> { + self.assert_did_seek(); + if let Some(entry) = self.stack.last() { + match *entry.tree.0 { + Node::Leaf { + ref item_summaries, .. + } => { + if entry.index() == item_summaries.len() { + None + } else { + Some(&item_summaries[entry.index()]) + } + } + _ => unreachable!(), + } + } else { + None + } + } + + #[track_caller] + pub fn next_item(&self) -> Option<&'a T> { + self.assert_did_seek(); + if let Some(entry) = self.stack.last() { + if entry.index() == entry.tree.0.items().len() - 1 { + if let Some(next_leaf) = self.next_leaf() { + Some(next_leaf.0.items().first().unwrap()) + } else { + None + } + } else { + match *entry.tree.0 { + Node::Leaf { ref items, .. } => Some(&items[entry.index() + 1]), + _ => unreachable!(), + } + } + } else if self.at_end { + None + } else { + self.tree.first() + } + } + + #[track_caller] + fn next_leaf(&self) -> Option<&'a SumTree> { + for entry in self.stack.iter().rev().skip(1) { + if entry.index() < entry.tree.0.child_trees().len() - 1 { + match *entry.tree.0 { + Node::Internal { + ref child_trees, .. + } => return Some(child_trees[entry.index() + 1].leftmost_leaf()), + Node::Leaf { .. } => unreachable!(), + }; + } + } + None + } + + #[track_caller] + pub fn prev_item(&self) -> Option<&'a T> { + self.assert_did_seek(); + if let Some(entry) = self.stack.last() { + if entry.index() == 0 { + if let Some(prev_leaf) = self.prev_leaf() { + Some(prev_leaf.0.items().last().unwrap()) + } else { + None + } + } else { + match *entry.tree.0 { + Node::Leaf { ref items, .. } => Some(&items[entry.index() - 1]), + _ => unreachable!(), + } + } + } else if self.at_end { + self.tree.last() + } else { + None + } + } + + #[track_caller] + fn prev_leaf(&self) -> Option<&'a SumTree> { + for entry in self.stack.iter().rev().skip(1) { + if entry.index() != 0 { + match *entry.tree.0 { + Node::Internal { + ref child_trees, .. + } => return Some(child_trees[entry.index() - 1].rightmost_leaf()), + Node::Leaf { .. } => unreachable!(), + }; + } + } + None + } + + #[track_caller] + pub fn prev(&mut self) { + self.search_backward(|_| true) + } + + #[track_caller] + pub fn search_backward(&mut self, mut filter_node: F) + where + F: FnMut(&T::Summary) -> bool, + { + if !self.did_seek { + self.did_seek = true; + self.at_end = true; + } + + if self.at_end { + self.position = D::zero(self.cx); + self.at_end = self.tree.is_empty(); + if !self.tree.is_empty() { + self.stack.push(StackEntry { + tree: self.tree, + index: self.tree.0.child_summaries().len() as u32, + position: D::from_summary(self.tree.summary(), self.cx), + }); + } + } + + let mut descending = false; + while !self.stack.is_empty() { + if let Some(StackEntry { position, .. }) = self.stack.iter().rev().nth(1) { + self.position = position.clone(); + } else { + self.position = D::zero(self.cx); + } + + let entry = self.stack.last_mut().unwrap(); + if !descending { + if entry.index() == 0 { + self.stack.pop(); + continue; + } else { + entry.index -= 1; + } + } + + for summary in &entry.tree.0.child_summaries()[..entry.index()] { + self.position.add_summary(summary, self.cx); + } + entry.position = self.position.clone(); + + descending = filter_node(&entry.tree.0.child_summaries()[entry.index()]); + match entry.tree.0.as_ref() { + Node::Internal { child_trees, .. } => { + if descending { + let tree = &child_trees[entry.index()]; + self.stack.push(StackEntry { + position: D::zero(self.cx), + tree, + index: tree.0.child_summaries().len() as u32 - 1, + }) + } + } + Node::Leaf { .. } => { + if descending { + break; + } + } + } + } + } + + #[track_caller] + pub fn next(&mut self) { + self.search_forward(|_| true) + } + + #[track_caller] + pub fn search_forward(&mut self, mut filter_node: F) + where + F: FnMut(&T::Summary) -> bool, + { + let mut descend = false; + + if self.stack.is_empty() { + if !self.at_end { + self.stack.push(StackEntry { + tree: self.tree, + index: 0, + position: D::zero(self.cx), + }); + descend = true; + } + self.did_seek = true; + } + + while !self.stack.is_empty() { + let new_subtree = { + let entry = self.stack.last_mut().unwrap(); + match entry.tree.0.as_ref() { + Node::Internal { + child_trees, + child_summaries, + .. + } => { + if !descend { + entry.index += 1; + entry.position = self.position.clone(); + } + + while entry.index() < child_summaries.len() { + let next_summary = &child_summaries[entry.index()]; + if filter_node(next_summary) { + break; + } else { + entry.index += 1; + entry.position.add_summary(next_summary, self.cx); + self.position.add_summary(next_summary, self.cx); + } + } + + child_trees.get(entry.index()) + } + Node::Leaf { item_summaries, .. } => { + if !descend { + let item_summary = &item_summaries[entry.index()]; + entry.index += 1; + entry.position.add_summary(item_summary, self.cx); + self.position.add_summary(item_summary, self.cx); + } + + loop { + if let Some(next_item_summary) = item_summaries.get(entry.index()) { + if filter_node(next_item_summary) { + return; + } else { + entry.index += 1; + entry.position.add_summary(next_item_summary, self.cx); + self.position.add_summary(next_item_summary, self.cx); + } + } else { + break None; + } + } + } + } + }; + + if let Some(subtree) = new_subtree { + descend = true; + self.stack.push(StackEntry { + tree: subtree, + index: 0, + position: self.position.clone(), + }); + } else { + descend = false; + self.stack.pop(); + } + } + + self.at_end = self.stack.is_empty(); + debug_assert!(self.stack.is_empty() || self.stack.last().unwrap().tree.0.is_leaf()); + } + + #[track_caller] + fn assert_did_seek(&self) { + assert!( + self.did_seek, + "Must call `seek`, `next` or `prev` before calling this method" + ); + } +} + +impl<'a, 'b, T, D> Cursor<'a, 'b, T, D> +where + T: Item, + D: Dimension<'a, T::Summary>, +{ + #[track_caller] + pub fn seek(&mut self, pos: &Target, bias: Bias) -> bool + where + Target: SeekTarget<'a, T::Summary, D>, + { + self.reset(); + self.seek_internal(pos, bias, &mut ()) + } + + #[track_caller] + pub fn seek_forward(&mut self, pos: &Target, bias: Bias) -> bool + where + Target: SeekTarget<'a, T::Summary, D>, + { + self.seek_internal(pos, bias, &mut ()) + } + + /// Advances the cursor and returns traversed items as a tree. + #[track_caller] + pub fn slice(&mut self, end: &Target, bias: Bias) -> SumTree + where + Target: SeekTarget<'a, T::Summary, D>, + { + let mut slice = SliceSeekAggregate { + tree: SumTree::new(self.cx), + leaf_items: ArrayVec::new(), + leaf_item_summaries: ArrayVec::new(), + leaf_summary: ::zero(self.cx), + }; + self.seek_internal(end, bias, &mut slice); + slice.tree + } + + #[track_caller] + pub fn suffix(&mut self) -> SumTree { + self.slice(&End::new(), Bias::Right) + } + + #[track_caller] + pub fn summary(&mut self, end: &Target, bias: Bias) -> Output + where + Target: SeekTarget<'a, T::Summary, D>, + Output: Dimension<'a, T::Summary>, + { + let mut summary = SummarySeekAggregate(Output::zero(self.cx)); + self.seek_internal(end, bias, &mut summary); + summary.0 + } + + /// Returns whether we found the item you were seeking for + #[track_caller] + fn seek_internal( + &mut self, + target: &dyn SeekTarget<'a, T::Summary, D>, + bias: Bias, + aggregate: &mut dyn SeekAggregate<'a, T>, + ) -> bool { + assert!( + target.cmp(&self.position, self.cx) >= Ordering::Equal, + "cannot seek backward", + ); + + if !self.did_seek { + self.did_seek = true; + self.stack.push(StackEntry { + tree: self.tree, + index: 0, + position: D::zero(self.cx), + }); + } + + let mut ascending = false; + 'outer: while let Some(entry) = self.stack.last_mut() { + match *entry.tree.0 { + Node::Internal { + ref child_summaries, + ref child_trees, + .. + } => { + if ascending { + entry.index += 1; + entry.position = self.position.clone(); + } + + for (child_tree, child_summary) in child_trees[entry.index()..] + .iter() + .zip(&child_summaries[entry.index()..]) + { + let mut child_end = self.position.clone(); + child_end.add_summary(child_summary, self.cx); + + let comparison = target.cmp(&child_end, self.cx); + if comparison == Ordering::Greater + || (comparison == Ordering::Equal && bias == Bias::Right) + { + self.position = child_end; + aggregate.push_tree(child_tree, child_summary, self.cx); + entry.index += 1; + entry.position = self.position.clone(); + } else { + self.stack.push(StackEntry { + tree: child_tree, + index: 0, + position: self.position.clone(), + }); + ascending = false; + continue 'outer; + } + } + } + Node::Leaf { + ref items, + ref item_summaries, + .. + } => { + aggregate.begin_leaf(); + + for (item, item_summary) in items[entry.index()..] + .iter() + .zip(&item_summaries[entry.index()..]) + { + let mut child_end = self.position.clone(); + child_end.add_summary(item_summary, self.cx); + + let comparison = target.cmp(&child_end, self.cx); + if comparison == Ordering::Greater + || (comparison == Ordering::Equal && bias == Bias::Right) + { + self.position = child_end; + aggregate.push_item(item, item_summary, self.cx); + entry.index += 1; + } else { + aggregate.end_leaf(self.cx); + break 'outer; + } + } + + aggregate.end_leaf(self.cx); + } + } + + self.stack.pop(); + ascending = true; + } + + self.at_end = self.stack.is_empty(); + debug_assert!(self.stack.is_empty() || self.stack.last().unwrap().tree.0.is_leaf()); + + let mut end = self.position.clone(); + if bias == Bias::Left + && let Some(summary) = self.item_summary() + { + end.add_summary(summary, self.cx); + } + + target.cmp(&end, self.cx) == Ordering::Equal + } +} + +impl<'a, T: Item> Iter<'a, T> { + pub(crate) fn new(tree: &'a SumTree) -> Self { + Self { + tree, + stack: Default::default(), + } + } +} + +impl<'a, T: Item> Iterator for Iter<'a, T> { + type Item = &'a T; + + fn next(&mut self) -> Option { + let mut descend = false; + + if self.stack.is_empty() { + self.stack.push(StackEntry { + tree: self.tree, + index: 0, + position: (), + }); + descend = true; + } + + while !self.stack.is_empty() { + let new_subtree = { + let entry = self.stack.last_mut().unwrap(); + match entry.tree.0.as_ref() { + Node::Internal { child_trees, .. } => { + if !descend { + entry.index += 1; + } + child_trees.get(entry.index()) + } + Node::Leaf { items, .. } => { + if !descend { + entry.index += 1; + } + + if let Some(next_item) = items.get(entry.index()) { + return Some(next_item); + } else { + None + } + } + } + }; + + if let Some(subtree) = new_subtree { + descend = true; + self.stack.push(StackEntry { + tree: subtree, + index: 0, + position: (), + }); + } else { + descend = false; + self.stack.pop(); + } + } + + None + } +} + +impl<'a, 'b, T: Item, D> Iterator for Cursor<'a, 'b, T, D> +where + D: Dimension<'a, T::Summary>, +{ + type Item = &'a T; + + fn next(&mut self) -> Option { + if !self.did_seek { + self.next(); + } + + if let Some(item) = self.item() { + self.next(); + Some(item) + } else { + None + } + } +} + +pub struct FilterCursor<'a, 'b, F, T: Item, D> { + cursor: Cursor<'a, 'b, T, D>, + filter_node: F, +} + +impl<'a, 'b, F, T: Item, D> FilterCursor<'a, 'b, F, T, D> +where + F: FnMut(&T::Summary) -> bool, + T: Item, + D: Dimension<'a, T::Summary>, +{ + pub fn new( + tree: &'a SumTree, + cx: ::Context<'b>, + filter_node: F, + ) -> Self { + let cursor = tree.cursor::(cx); + Self { + cursor, + filter_node, + } + } + + pub fn start(&self) -> &D { + self.cursor.start() + } + + pub fn end(&self) -> D { + self.cursor.end() + } + + pub fn item(&self) -> Option<&'a T> { + self.cursor.item() + } + + pub fn item_summary(&self) -> Option<&'a T::Summary> { + self.cursor.item_summary() + } + + pub fn next(&mut self) { + self.cursor.search_forward(&mut self.filter_node); + } + + pub fn prev(&mut self) { + self.cursor.search_backward(&mut self.filter_node); + } +} + +impl<'a, 'b, F, T: Item, U> Iterator for FilterCursor<'a, 'b, F, T, U> +where + F: FnMut(&T::Summary) -> bool, + U: Dimension<'a, T::Summary>, +{ + type Item = &'a T; + + fn next(&mut self) -> Option { + if !self.cursor.did_seek { + self.next(); + } + + if let Some(item) = self.item() { + self.cursor.search_forward(&mut self.filter_node); + Some(item) + } else { + None + } + } +} + +trait SeekAggregate<'a, T: Item> { + fn begin_leaf(&mut self); + fn end_leaf(&mut self, cx: ::Context<'_>); + fn push_item( + &mut self, + item: &'a T, + summary: &'a T::Summary, + cx: ::Context<'_>, + ); + fn push_tree( + &mut self, + tree: &'a SumTree, + summary: &'a T::Summary, + cx: ::Context<'_>, + ); +} + +struct SliceSeekAggregate { + tree: SumTree, + leaf_items: ArrayVec, + leaf_item_summaries: ArrayVec, + leaf_summary: T::Summary, +} + +struct SummarySeekAggregate(D); + +impl SeekAggregate<'_, T> for () { + fn begin_leaf(&mut self) {} + fn end_leaf(&mut self, _: ::Context<'_>) {} + fn push_item(&mut self, _: &T, _: &T::Summary, _: ::Context<'_>) {} + fn push_tree( + &mut self, + _: &SumTree, + _: &T::Summary, + _: ::Context<'_>, + ) { + } +} + +impl SeekAggregate<'_, T> for SliceSeekAggregate { + fn begin_leaf(&mut self) {} + fn end_leaf(&mut self, cx: ::Context<'_>) { + self.tree.append( + SumTree(Arc::new(Node::Leaf { + summary: mem::replace(&mut self.leaf_summary, ::zero(cx)), + items: mem::take(&mut self.leaf_items), + item_summaries: mem::take(&mut self.leaf_item_summaries), + })), + cx, + ); + } + fn push_item( + &mut self, + item: &T, + summary: &T::Summary, + cx: ::Context<'_>, + ) { + self.leaf_items.push(item.clone()); + self.leaf_item_summaries.push(summary.clone()); + Summary::add_summary(&mut self.leaf_summary, summary, cx); + } + fn push_tree( + &mut self, + tree: &SumTree, + _: &T::Summary, + cx: ::Context<'_>, + ) { + self.tree.append(tree.clone(), cx); + } +} + +impl<'a, T: Item, D> SeekAggregate<'a, T> for SummarySeekAggregate +where + D: Dimension<'a, T::Summary>, +{ + fn begin_leaf(&mut self) {} + fn end_leaf(&mut self, _: ::Context<'_>) {} + fn push_item( + &mut self, + _: &T, + summary: &'a T::Summary, + cx: ::Context<'_>, + ) { + self.0.add_summary(summary, cx); + } + fn push_tree( + &mut self, + _: &SumTree, + summary: &'a T::Summary, + cx: ::Context<'_>, + ) { + self.0.add_summary(summary, cx); + } +} + +struct End(PhantomData); + +impl End { + fn new() -> Self { + Self(PhantomData) + } +} + +impl<'a, S: Summary, D: Dimension<'a, S>> SeekTarget<'a, S, D> for End { + fn cmp(&self, _: &D, _: S::Context<'_>) -> Ordering { + Ordering::Greater + } +} + +impl fmt::Debug for End { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_tuple("End").finish() + } +} diff --git a/third_party/gpui_support/zed-sum-tree/src/sum_tree.rs b/third_party/gpui_support/zed-sum-tree/src/sum_tree.rs new file mode 100644 index 0000000..bfd2423 --- /dev/null +++ b/third_party/gpui_support/zed-sum-tree/src/sum_tree.rs @@ -0,0 +1,1444 @@ +mod cursor; +mod tree_map; + +use arrayvec::ArrayVec; +pub use cursor::{Cursor, FilterCursor, Iter}; +use rayon::prelude::*; +use std::marker::PhantomData; +use std::mem; +use std::{cmp::Ordering, fmt, iter::FromIterator, sync::Arc}; +pub use tree_map::{MapSeekTarget, TreeMap, TreeSet}; + +#[cfg(test)] +pub const TREE_BASE: usize = 2; +#[cfg(not(test))] +pub const TREE_BASE: usize = 6; + +/// An item that can be stored in a [`SumTree`] +/// +/// Must be summarized by a type that implements [`Summary`] +pub trait Item: Clone { + type Summary: Summary; + + fn summary(&self, cx: ::Context<'_>) -> Self::Summary; +} + +/// An [`Item`] whose summary has a specific key that can be used to identify it +pub trait KeyedItem: Item { + type Key: for<'a> Dimension<'a, Self::Summary> + Ord; + + fn key(&self) -> Self::Key; +} + +/// A type that describes the Sum of all [`Item`]s in a subtree of the [`SumTree`] +/// +/// Each Summary type can have multiple [`Dimension`]s that it measures, +/// which can be used to navigate the tree +pub trait Summary: Clone { + type Context<'a>: Copy; + fn zero<'a>(cx: Self::Context<'a>) -> Self; + fn add_summary<'a>(&mut self, summary: &Self, cx: Self::Context<'a>); +} + +pub trait ContextLessSummary: Clone { + fn zero() -> Self; + fn add_summary(&mut self, summary: &Self); +} + +impl Summary for T { + type Context<'a> = (); + + fn zero<'a>((): ()) -> Self { + T::zero() + } + + fn add_summary<'a>(&mut self, summary: &Self, (): ()) { + T::add_summary(self, summary) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct NoSummary; + +/// Catch-all implementation for when you need something that implements [`Summary`] without a specific type. +/// We implement it on a `NoSummary` instead of re-using `()`, as that avoids blanket impl collisions with `impl Dimension for T` +/// (as we also need unit type to be a fill-in dimension) +impl ContextLessSummary for NoSummary { + fn zero() -> Self { + NoSummary + } + + fn add_summary(&mut self, _: &Self) {} +} + +/// Each [`Summary`] type can have more than one [`Dimension`] type that it measures. +/// +/// You can use dimensions to seek to a specific location in the [`SumTree`] +/// +/// # Example: +/// Zed's rope has a `TextSummary` type that summarizes lines, characters, and bytes. +/// Each of these are different dimensions we may want to seek to +pub trait Dimension<'a, S: Summary>: Clone { + fn zero(cx: S::Context<'_>) -> Self; + + fn add_summary(&mut self, summary: &'a S, cx: S::Context<'_>); + + fn from_summary(summary: &'a S, cx: S::Context<'_>) -> Self { + let mut dimension = Self::zero(cx); + dimension.add_summary(summary, cx); + dimension + } +} + +impl<'a, T: Summary> Dimension<'a, T> for T { + fn zero(cx: T::Context<'_>) -> Self { + Summary::zero(cx) + } + + fn add_summary(&mut self, summary: &'a T, cx: T::Context<'_>) { + Summary::add_summary(self, summary, cx); + } +} + +pub trait SeekTarget<'a, S: Summary, D: Dimension<'a, S>> { + fn cmp(&self, cursor_location: &D, cx: S::Context<'_>) -> Ordering; +} + +impl<'a, S: Summary, D: Dimension<'a, S> + Ord> SeekTarget<'a, S, D> for D { + fn cmp(&self, cursor_location: &Self, _: S::Context<'_>) -> Ordering { + Ord::cmp(self, cursor_location) + } +} + +impl<'a, T: Summary> Dimension<'a, T> for () { + fn zero(_: T::Context<'_>) -> Self {} + + fn add_summary(&mut self, _: &'a T, _: T::Context<'_>) {} +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)] +pub struct Dimensions(pub D1, pub D2, pub D3); + +impl<'a, T: Summary, D1: Dimension<'a, T>, D2: Dimension<'a, T>, D3: Dimension<'a, T>> + Dimension<'a, T> for Dimensions +{ + fn zero(cx: T::Context<'_>) -> Self { + Dimensions(D1::zero(cx), D2::zero(cx), D3::zero(cx)) + } + + fn add_summary(&mut self, summary: &'a T, cx: T::Context<'_>) { + self.0.add_summary(summary, cx); + self.1.add_summary(summary, cx); + self.2.add_summary(summary, cx); + } +} + +impl<'a, S, D1, D2, D3> SeekTarget<'a, S, Dimensions> for D1 +where + S: Summary, + D1: SeekTarget<'a, S, D1> + Dimension<'a, S>, + D2: Dimension<'a, S>, + D3: Dimension<'a, S>, +{ + fn cmp(&self, cursor_location: &Dimensions, cx: S::Context<'_>) -> Ordering { + self.cmp(&cursor_location.0, cx) + } +} + +/// Bias is used to settle ambiguities when determining positions in an ordered sequence. +/// +/// The primary use case is for text, where Bias influences +/// which character an offset or anchor is associated with. +/// +/// # Examples +/// Given the buffer `AˇBCD`: +/// - The offset of the cursor is 1 +/// - [Bias::Left] would attach the cursor to the character `A` +/// - [Bias::Right] would attach the cursor to the character `B` +/// +/// Given the buffer `A«BCˇ»D`: +/// - The offset of the cursor is 3, and the selection is from 1 to 3 +/// - The left anchor of the selection has [Bias::Right], attaching it to the character `B` +/// - The right anchor of the selection has [Bias::Left], attaching it to the character `C` +/// +/// Given the buffer `{ˇ<...>`, where `<...>` is a folded region: +/// - The display offset of the cursor is 1, but the offset in the buffer is determined by the bias +/// - [Bias::Left] would attach the cursor to the character `{`, with a buffer offset of 1 +/// - [Bias::Right] would attach the cursor to the first character of the folded region, +/// and the buffer offset would be the offset of the first character of the folded region +#[derive(Copy, Clone, Eq, PartialEq, PartialOrd, Ord, Debug, Hash, Default)] +pub enum Bias { + /// Attach to the character on the left + #[default] + Left, + /// Attach to the character on the right + Right, +} + +impl Bias { + pub fn invert(self) -> Self { + match self { + Self::Left => Self::Right, + Self::Right => Self::Left, + } + } +} + +/// A B+ tree in which each leaf node contains `Item`s of type `T` and a `Summary`s for each `Item`. +/// Each internal node contains a `Summary` of the items in its subtree. +/// +/// The maximum number of items per node is `TREE_BASE * 2`. +/// +/// Any [`Dimension`] supported by the [`Summary`] type can be used to seek to a specific location in the tree. +#[derive(Clone)] +pub struct SumTree(Arc>); + +impl fmt::Debug for SumTree +where + T: fmt::Debug + Item, + T::Summary: fmt::Debug, +{ + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.debug_tuple("SumTree").field(&self.0).finish() + } +} + +impl SumTree { + pub fn new(cx: ::Context<'_>) -> Self { + SumTree(Arc::new(Node::Leaf { + summary: ::zero(cx), + items: ArrayVec::new(), + item_summaries: ArrayVec::new(), + })) + } + + /// Useful in cases where the item type has a non-trivial context type, but the zero value of the summary type doesn't depend on that context. + pub fn from_summary(summary: T::Summary) -> Self { + SumTree(Arc::new(Node::Leaf { + summary, + items: ArrayVec::new(), + item_summaries: ArrayVec::new(), + })) + } + + pub fn from_item(item: T, cx: ::Context<'_>) -> Self { + let mut tree = Self::new(cx); + tree.push(item, cx); + tree + } + + pub fn from_iter>( + iter: I, + cx: ::Context<'_>, + ) -> Self { + let mut nodes = Vec::new(); + + let mut iter = iter.into_iter().fuse().peekable(); + while iter.peek().is_some() { + let items: ArrayVec = iter.by_ref().take(2 * TREE_BASE).collect(); + let item_summaries: ArrayVec = + items.iter().map(|item| item.summary(cx)).collect(); + + let mut summary = item_summaries[0].clone(); + for item_summary in &item_summaries[1..] { + ::add_summary(&mut summary, item_summary, cx); + } + + nodes.push(Node::Leaf { + summary, + items, + item_summaries, + }); + } + + let mut parent_nodes = Vec::new(); + let mut height = 0; + while nodes.len() > 1 { + height += 1; + let mut current_parent_node = None; + for child_node in nodes.drain(..) { + let parent_node = current_parent_node.get_or_insert_with(|| Node::Internal { + summary: ::zero(cx), + height, + child_summaries: ArrayVec::new(), + child_trees: ArrayVec::new(), + }); + let Node::Internal { + summary, + child_summaries, + child_trees, + .. + } = parent_node + else { + unreachable!() + }; + let child_summary = child_node.summary(); + ::add_summary(summary, child_summary, cx); + child_summaries.push(child_summary.clone()); + child_trees.push(Self(Arc::new(child_node))); + + if child_trees.len() == 2 * TREE_BASE { + parent_nodes.extend(current_parent_node.take()); + } + } + parent_nodes.extend(current_parent_node.take()); + mem::swap(&mut nodes, &mut parent_nodes); + } + + if nodes.is_empty() { + Self::new(cx) + } else { + debug_assert_eq!(nodes.len(), 1); + Self(Arc::new(nodes.pop().unwrap())) + } + } + + pub fn from_par_iter(iter: I, cx: ::Context<'_>) -> Self + where + I: IntoParallelIterator, + Iter: IndexedParallelIterator, + T: Send + Sync, + T::Summary: Send + Sync, + for<'a> ::Context<'a>: Sync, + { + let mut nodes = iter + .into_par_iter() + .chunks(2 * TREE_BASE) + .map(|items| { + let items: ArrayVec = items.into_iter().collect(); + let item_summaries: ArrayVec = + items.iter().map(|item| item.summary(cx)).collect(); + let mut summary = item_summaries[0].clone(); + for item_summary in &item_summaries[1..] { + ::add_summary(&mut summary, item_summary, cx); + } + SumTree(Arc::new(Node::Leaf { + summary, + items, + item_summaries, + })) + }) + .collect::>(); + + let mut height = 0; + while nodes.len() > 1 { + height += 1; + nodes = nodes + .into_par_iter() + .chunks(2 * TREE_BASE) + .map(|child_nodes| { + let child_trees: ArrayVec, { 2 * TREE_BASE }> = + child_nodes.into_iter().collect(); + let child_summaries: ArrayVec = child_trees + .iter() + .map(|child_tree| child_tree.summary().clone()) + .collect(); + let mut summary = child_summaries[0].clone(); + for child_summary in &child_summaries[1..] { + ::add_summary(&mut summary, child_summary, cx); + } + SumTree(Arc::new(Node::Internal { + height, + summary, + child_summaries, + child_trees, + })) + }) + .collect::>(); + } + + if nodes.is_empty() { + Self::new(cx) + } else { + debug_assert_eq!(nodes.len(), 1); + nodes.pop().unwrap() + } + } + + #[allow(unused)] + pub fn items<'a>(&'a self, cx: ::Context<'a>) -> Vec { + let mut items = Vec::new(); + let mut cursor = self.cursor::<()>(cx); + cursor.next(); + while let Some(item) = cursor.item() { + items.push(item.clone()); + cursor.next(); + } + items + } + + pub fn iter(&self) -> Iter<'_, T> { + Iter::new(self) + } + + pub fn cursor<'a, 'b, S>( + &'a self, + cx: ::Context<'b>, + ) -> Cursor<'a, 'b, T, S> + where + S: Dimension<'a, T::Summary>, + { + Cursor::new(self, cx) + } + + /// Note: If the summary type requires a non `()` context, then the filter cursor + /// that is returned cannot be used with Rust's iterators. + pub fn filter<'a, 'b, F, U>( + &'a self, + cx: ::Context<'b>, + filter_node: F, + ) -> FilterCursor<'a, 'b, F, T, U> + where + F: FnMut(&T::Summary) -> bool, + U: Dimension<'a, T::Summary>, + { + FilterCursor::new(self, cx, filter_node) + } + + #[allow(dead_code)] + pub fn first(&self) -> Option<&T> { + self.leftmost_leaf().0.items().first() + } + + pub fn last(&self) -> Option<&T> { + self.rightmost_leaf().0.items().last() + } + + pub fn update_last( + &mut self, + f: impl FnOnce(&mut T), + cx: ::Context<'_>, + ) { + self.update_last_recursive(f, cx); + } + + fn update_last_recursive( + &mut self, + f: impl FnOnce(&mut T), + cx: ::Context<'_>, + ) -> Option { + match Arc::make_mut(&mut self.0) { + Node::Internal { + summary, + child_summaries, + child_trees, + .. + } => { + let last_summary = child_summaries.last_mut().unwrap(); + let last_child = child_trees.last_mut().unwrap(); + *last_summary = last_child.update_last_recursive(f, cx).unwrap(); + *summary = sum(child_summaries.iter(), cx); + Some(summary.clone()) + } + Node::Leaf { + summary, + items, + item_summaries, + } => { + if let Some((item, item_summary)) = items.last_mut().zip(item_summaries.last_mut()) + { + (f)(item); + *item_summary = item.summary(cx); + *summary = sum(item_summaries.iter(), cx); + Some(summary.clone()) + } else { + None + } + } + } + } + + pub fn extent<'a, D: Dimension<'a, T::Summary>>( + &'a self, + cx: ::Context<'_>, + ) -> D { + let mut extent = D::zero(cx); + match self.0.as_ref() { + Node::Internal { summary, .. } | Node::Leaf { summary, .. } => { + extent.add_summary(summary, cx); + } + } + extent + } + + pub fn summary(&self) -> &T::Summary { + match self.0.as_ref() { + Node::Internal { summary, .. } => summary, + Node::Leaf { summary, .. } => summary, + } + } + + pub fn is_empty(&self) -> bool { + match self.0.as_ref() { + Node::Internal { .. } => false, + Node::Leaf { items, .. } => items.is_empty(), + } + } + + pub fn extend(&mut self, iter: I, cx: ::Context<'_>) + where + I: IntoIterator, + { + self.append(Self::from_iter(iter, cx), cx); + } + + pub fn par_extend(&mut self, iter: I, cx: ::Context<'_>) + where + I: IntoParallelIterator, + Iter: IndexedParallelIterator, + T: Send + Sync, + T::Summary: Send + Sync, + for<'a> ::Context<'a>: Sync, + { + self.append(Self::from_par_iter(iter, cx), cx); + } + + pub fn push(&mut self, item: T, cx: ::Context<'_>) { + let summary = item.summary(cx); + self.append( + SumTree(Arc::new(Node::Leaf { + summary: summary.clone(), + items: ArrayVec::from_iter(Some(item)), + item_summaries: ArrayVec::from_iter(Some(summary)), + })), + cx, + ); + } + + pub fn append(&mut self, other: Self, cx: ::Context<'_>) { + if self.is_empty() { + *self = other; + } else if !other.0.is_leaf() || !other.0.items().is_empty() { + if self.0.height() < other.0.height() { + for tree in other.0.child_trees() { + self.append(tree.clone(), cx); + } + } else if let Some(split_tree) = self.push_tree_recursive(other, cx) { + *self = Self::from_child_trees(self.clone(), split_tree, cx); + } + } + } + + fn push_tree_recursive( + &mut self, + other: SumTree, + cx: ::Context<'_>, + ) -> Option> { + match Arc::make_mut(&mut self.0) { + Node::Internal { + height, + summary, + child_summaries, + child_trees, + .. + } => { + let other_node = other.0.clone(); + ::add_summary(summary, other_node.summary(), cx); + + let height_delta = *height - other_node.height(); + let mut summaries_to_append = ArrayVec::::new(); + let mut trees_to_append = ArrayVec::, { 2 * TREE_BASE }>::new(); + if height_delta == 0 { + summaries_to_append.extend(other_node.child_summaries().iter().cloned()); + trees_to_append.extend(other_node.child_trees().iter().cloned()); + } else if height_delta == 1 && !other_node.is_underflowing() { + summaries_to_append.push(other_node.summary().clone()); + trees_to_append.push(other) + } else { + let tree_to_append = child_trees + .last_mut() + .unwrap() + .push_tree_recursive(other, cx); + *child_summaries.last_mut().unwrap() = + child_trees.last().unwrap().0.summary().clone(); + + if let Some(split_tree) = tree_to_append { + summaries_to_append.push(split_tree.0.summary().clone()); + trees_to_append.push(split_tree); + } + } + + let child_count = child_trees.len() + trees_to_append.len(); + if child_count > 2 * TREE_BASE { + let left_summaries: ArrayVec<_, { 2 * TREE_BASE }>; + let right_summaries: ArrayVec<_, { 2 * TREE_BASE }>; + let left_trees; + let right_trees; + + let midpoint = (child_count + child_count % 2) / 2; + { + let mut all_summaries = child_summaries + .iter() + .chain(summaries_to_append.iter()) + .cloned(); + left_summaries = all_summaries.by_ref().take(midpoint).collect(); + right_summaries = all_summaries.collect(); + let mut all_trees = + child_trees.iter().chain(trees_to_append.iter()).cloned(); + left_trees = all_trees.by_ref().take(midpoint).collect(); + right_trees = all_trees.collect(); + } + *summary = sum(left_summaries.iter(), cx); + *child_summaries = left_summaries; + *child_trees = left_trees; + + Some(SumTree(Arc::new(Node::Internal { + height: *height, + summary: sum(right_summaries.iter(), cx), + child_summaries: right_summaries, + child_trees: right_trees, + }))) + } else { + child_summaries.extend(summaries_to_append); + child_trees.extend(trees_to_append); + None + } + } + Node::Leaf { + summary, + items, + item_summaries, + } => { + let other_node = other.0; + + let child_count = items.len() + other_node.items().len(); + if child_count > 2 * TREE_BASE { + let left_items; + let right_items; + let left_summaries; + let right_summaries: ArrayVec; + + let midpoint = (child_count + child_count % 2) / 2; + { + let mut all_items = items.iter().chain(other_node.items().iter()).cloned(); + left_items = all_items.by_ref().take(midpoint).collect(); + right_items = all_items.collect(); + + let mut all_summaries = item_summaries + .iter() + .chain(other_node.child_summaries()) + .cloned(); + left_summaries = all_summaries.by_ref().take(midpoint).collect(); + right_summaries = all_summaries.collect(); + } + *items = left_items; + *item_summaries = left_summaries; + *summary = sum(item_summaries.iter(), cx); + Some(SumTree(Arc::new(Node::Leaf { + items: right_items, + summary: sum(right_summaries.iter(), cx), + item_summaries: right_summaries, + }))) + } else { + ::add_summary(summary, other_node.summary(), cx); + items.extend(other_node.items().iter().cloned()); + item_summaries.extend(other_node.child_summaries().iter().cloned()); + None + } + } + } + } + + fn from_child_trees( + left: SumTree, + right: SumTree, + cx: ::Context<'_>, + ) -> Self { + let height = left.0.height() + 1; + let mut child_summaries = ArrayVec::new(); + child_summaries.push(left.0.summary().clone()); + child_summaries.push(right.0.summary().clone()); + let mut child_trees = ArrayVec::new(); + child_trees.push(left); + child_trees.push(right); + SumTree(Arc::new(Node::Internal { + height, + summary: sum(child_summaries.iter(), cx), + child_summaries, + child_trees, + })) + } + + fn leftmost_leaf(&self) -> &Self { + match *self.0 { + Node::Leaf { .. } => self, + Node::Internal { + ref child_trees, .. + } => child_trees.first().unwrap().leftmost_leaf(), + } + } + + fn rightmost_leaf(&self) -> &Self { + match *self.0 { + Node::Leaf { .. } => self, + Node::Internal { + ref child_trees, .. + } => child_trees.last().unwrap().rightmost_leaf(), + } + } +} + +impl PartialEq for SumTree { + fn eq(&self, other: &Self) -> bool { + self.iter().eq(other.iter()) + } +} + +impl Eq for SumTree {} + +impl SumTree { + pub fn insert_or_replace<'a, 'b>( + &'a mut self, + item: T, + cx: ::Context<'b>, + ) -> Option { + let mut replaced = None; + { + let mut cursor = self.cursor::(cx); + let mut new_tree = cursor.slice(&item.key(), Bias::Left); + if let Some(cursor_item) = cursor.item() + && cursor_item.key() == item.key() + { + replaced = Some(cursor_item.clone()); + cursor.next(); + } + new_tree.push(item, cx); + new_tree.append(cursor.suffix(), cx); + drop(cursor); + *self = new_tree + }; + replaced + } + + pub fn remove(&mut self, key: &T::Key, cx: ::Context<'_>) -> Option { + let mut removed = None; + *self = { + let mut cursor = self.cursor::(cx); + let mut new_tree = cursor.slice(key, Bias::Left); + if let Some(item) = cursor.item() + && item.key() == *key + { + removed = Some(item.clone()); + cursor.next(); + } + new_tree.append(cursor.suffix(), cx); + new_tree + }; + removed + } + + pub fn edit( + &mut self, + mut edits: Vec>, + cx: ::Context<'_>, + ) -> Vec { + if edits.is_empty() { + return Vec::new(); + } + + let mut removed = Vec::new(); + edits.sort_unstable_by_key(|item| item.key()); + + *self = { + let mut cursor = self.cursor::(cx); + let mut new_tree = SumTree::new(cx); + let mut buffered_items = Vec::new(); + + cursor.seek(&T::Key::zero(cx), Bias::Left); + for edit in edits { + let new_key = edit.key(); + let mut old_item = cursor.item(); + + if old_item + .as_ref() + .is_some_and(|old_item| old_item.key() < new_key) + { + new_tree.extend(buffered_items.drain(..), cx); + let slice = cursor.slice(&new_key, Bias::Left); + new_tree.append(slice, cx); + old_item = cursor.item(); + } + + if let Some(old_item) = old_item + && old_item.key() == new_key + { + removed.push(old_item.clone()); + cursor.next(); + } + + match edit { + Edit::Insert(item) => { + buffered_items.push(item); + } + Edit::Remove(_) => {} + } + } + + new_tree.extend(buffered_items, cx); + new_tree.append(cursor.suffix(), cx); + new_tree + }; + + removed + } + + pub fn get<'a>( + &'a self, + key: &T::Key, + cx: ::Context<'a>, + ) -> Option<&'a T> { + let mut cursor = self.cursor::(cx); + if cursor.seek(key, Bias::Left) { + cursor.item() + } else { + None + } + } +} + +impl Default for SumTree +where + T: Item, + S: for<'a> Summary = ()>, +{ + fn default() -> Self { + Self::new(()) + } +} + +#[derive(Clone)] +pub enum Node { + Internal { + height: u8, + summary: T::Summary, + child_summaries: ArrayVec, + child_trees: ArrayVec, { 2 * TREE_BASE }>, + }, + Leaf { + summary: T::Summary, + items: ArrayVec, + item_summaries: ArrayVec, + }, +} + +impl fmt::Debug for Node +where + T: Item + fmt::Debug, + T::Summary: fmt::Debug, +{ + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Node::Internal { + height, + summary, + child_summaries, + child_trees, + } => f + .debug_struct("Internal") + .field("height", height) + .field("summary", summary) + .field("child_summaries", child_summaries) + .field("child_trees", child_trees) + .finish(), + Node::Leaf { + summary, + items, + item_summaries, + } => f + .debug_struct("Leaf") + .field("summary", summary) + .field("items", items) + .field("item_summaries", item_summaries) + .finish(), + } + } +} + +impl Node { + fn is_leaf(&self) -> bool { + matches!(self, Node::Leaf { .. }) + } + + fn height(&self) -> u8 { + match self { + Node::Internal { height, .. } => *height, + Node::Leaf { .. } => 0, + } + } + + fn summary(&self) -> &T::Summary { + match self { + Node::Internal { summary, .. } => summary, + Node::Leaf { summary, .. } => summary, + } + } + + fn child_summaries(&self) -> &[T::Summary] { + match self { + Node::Internal { + child_summaries, .. + } => child_summaries.as_slice(), + Node::Leaf { item_summaries, .. } => item_summaries.as_slice(), + } + } + + fn child_trees(&self) -> &ArrayVec, { 2 * TREE_BASE }> { + match self { + Node::Internal { child_trees, .. } => child_trees, + Node::Leaf { .. } => panic!("Leaf nodes have no child trees"), + } + } + + fn items(&self) -> &ArrayVec { + match self { + Node::Leaf { items, .. } => items, + Node::Internal { .. } => panic!("Internal nodes have no items"), + } + } + + fn is_underflowing(&self) -> bool { + match self { + Node::Internal { child_trees, .. } => child_trees.len() < TREE_BASE, + Node::Leaf { items, .. } => items.len() < TREE_BASE, + } + } +} + +#[derive(Debug)] +pub enum Edit { + Insert(T), + Remove(T::Key), +} + +impl Edit { + fn key(&self) -> T::Key { + match self { + Edit::Insert(item) => item.key(), + Edit::Remove(key) => key.clone(), + } + } +} + +fn sum<'a, T, I>(iter: I, cx: T::Context<'_>) -> T +where + T: 'a + Summary, + I: Iterator, +{ + let mut sum = T::zero(cx); + for value in iter { + sum.add_summary(value, cx); + } + sum +} + +#[cfg(test)] +mod tests { + use super::*; + use rand::{distr::StandardUniform, prelude::*}; + use std::cmp; + + #[ctor::ctor] + fn init_logger() { + zlog::init_test(); + } + + #[test] + fn test_extend_and_push_tree() { + let mut tree1 = SumTree::default(); + tree1.extend(0..20, ()); + + let mut tree2 = SumTree::default(); + tree2.extend(50..100, ()); + + tree1.append(tree2, ()); + assert_eq!(tree1.items(()), (0..20).chain(50..100).collect::>()); + } + + #[test] + fn test_random() { + let mut starting_seed = 0; + if let Ok(value) = std::env::var("SEED") { + starting_seed = value.parse().expect("invalid SEED variable"); + } + let mut num_iterations = 100; + if let Ok(value) = std::env::var("ITERATIONS") { + num_iterations = value.parse().expect("invalid ITERATIONS variable"); + } + let num_operations = std::env::var("OPERATIONS") + .map_or(5, |o| o.parse().expect("invalid OPERATIONS variable")); + + for seed in starting_seed..(starting_seed + num_iterations) { + eprintln!("seed = {}", seed); + let mut rng = StdRng::seed_from_u64(seed); + + let rng = &mut rng; + let mut tree = SumTree::::default(); + let count = rng.random_range(0..10); + if rng.random() { + tree.extend(rng.sample_iter(StandardUniform).take(count), ()); + } else { + let items = rng + .sample_iter(StandardUniform) + .take(count) + .collect::>(); + tree.par_extend(items, ()); + } + + for _ in 0..num_operations { + let splice_end = rng.random_range(0..tree.extent::(()).0 + 1); + let splice_start = rng.random_range(0..splice_end + 1); + let count = rng.random_range(0..10); + let tree_end = tree.extent::(()); + let new_items = rng + .sample_iter(StandardUniform) + .take(count) + .collect::>(); + + let mut reference_items = tree.items(()); + reference_items.splice(splice_start..splice_end, new_items.clone()); + + tree = { + let mut cursor = tree.cursor::(()); + let mut new_tree = cursor.slice(&Count(splice_start), Bias::Right); + if rng.random() { + new_tree.extend(new_items, ()); + } else { + new_tree.par_extend(new_items, ()); + } + cursor.seek(&Count(splice_end), Bias::Right); + new_tree.append(cursor.slice(&tree_end, Bias::Right), ()); + new_tree + }; + + assert_eq!(tree.items(()), reference_items); + assert_eq!( + tree.iter().collect::>(), + tree.cursor::<()>(()).collect::>() + ); + + log::info!("tree items: {:?}", tree.items(())); + + let mut filter_cursor = + tree.filter::<_, Count>((), |summary| summary.contains_even); + let expected_filtered_items = tree + .items(()) + .into_iter() + .enumerate() + .filter(|(_, item)| (item & 1) == 0) + .collect::>(); + + let mut item_ix = if rng.random() { + filter_cursor.next(); + 0 + } else { + filter_cursor.prev(); + expected_filtered_items.len().saturating_sub(1) + }; + while item_ix < expected_filtered_items.len() { + log::info!("filter_cursor, item_ix: {}", item_ix); + let actual_item = filter_cursor.item().unwrap(); + let (reference_index, reference_item) = expected_filtered_items[item_ix]; + assert_eq!(actual_item, &reference_item); + assert_eq!(filter_cursor.start().0, reference_index); + log::info!("next"); + filter_cursor.next(); + item_ix += 1; + + while item_ix > 0 && rng.random_bool(0.2) { + log::info!("prev"); + filter_cursor.prev(); + item_ix -= 1; + + if item_ix == 0 && rng.random_bool(0.2) { + filter_cursor.prev(); + assert_eq!(filter_cursor.item(), None); + assert_eq!(filter_cursor.start().0, 0); + filter_cursor.next(); + } + } + } + assert_eq!(filter_cursor.item(), None); + + let mut before_start = false; + let mut cursor = tree.cursor::(()); + let start_pos = rng.random_range(0..=reference_items.len()); + cursor.seek(&Count(start_pos), Bias::Right); + let mut pos = rng.random_range(start_pos..=reference_items.len()); + cursor.seek_forward(&Count(pos), Bias::Right); + + for i in 0..10 { + assert_eq!(cursor.start().0, pos); + + if pos > 0 { + assert_eq!(cursor.prev_item().unwrap(), &reference_items[pos - 1]); + } else { + assert_eq!(cursor.prev_item(), None); + } + + if pos < reference_items.len() && !before_start { + assert_eq!(cursor.item().unwrap(), &reference_items[pos]); + } else { + assert_eq!(cursor.item(), None); + } + + if before_start { + assert_eq!(cursor.next_item(), reference_items.first()); + } else if pos + 1 < reference_items.len() { + assert_eq!(cursor.next_item().unwrap(), &reference_items[pos + 1]); + } else { + assert_eq!(cursor.next_item(), None); + } + + if i < 5 { + cursor.next(); + if pos < reference_items.len() { + pos += 1; + before_start = false; + } + } else { + cursor.prev(); + if pos == 0 { + before_start = true; + } + pos = pos.saturating_sub(1); + } + } + } + + for _ in 0..10 { + let end = rng.random_range(0..tree.extent::(()).0 + 1); + let start = rng.random_range(0..end + 1); + let start_bias = if rng.random() { + Bias::Left + } else { + Bias::Right + }; + let end_bias = if rng.random() { + Bias::Left + } else { + Bias::Right + }; + + let mut cursor = tree.cursor::(()); + cursor.seek(&Count(start), start_bias); + let slice = cursor.slice(&Count(end), end_bias); + + cursor.seek(&Count(start), start_bias); + let summary = cursor.summary::<_, Sum>(&Count(end), end_bias); + + assert_eq!(summary.0, slice.summary().sum); + } + } + } + + #[test] + fn test_cursor() { + // Empty tree + let tree = SumTree::::default(); + let mut cursor = tree.cursor::(()); + assert_eq!( + cursor.slice(&Count(0), Bias::Right).items(()), + Vec::::new() + ); + assert_eq!(cursor.item(), None); + assert_eq!(cursor.prev_item(), None); + assert_eq!(cursor.next_item(), None); + assert_eq!(cursor.start().sum, 0); + cursor.prev(); + assert_eq!(cursor.item(), None); + assert_eq!(cursor.prev_item(), None); + assert_eq!(cursor.next_item(), None); + assert_eq!(cursor.start().sum, 0); + cursor.next(); + assert_eq!(cursor.item(), None); + assert_eq!(cursor.prev_item(), None); + assert_eq!(cursor.next_item(), None); + assert_eq!(cursor.start().sum, 0); + + // Single-element tree + let mut tree = SumTree::::default(); + tree.extend(vec![1], ()); + let mut cursor = tree.cursor::(()); + assert_eq!( + cursor.slice(&Count(0), Bias::Right).items(()), + Vec::::new() + ); + assert_eq!(cursor.item(), Some(&1)); + assert_eq!(cursor.prev_item(), None); + assert_eq!(cursor.next_item(), None); + assert_eq!(cursor.start().sum, 0); + + cursor.next(); + assert_eq!(cursor.item(), None); + assert_eq!(cursor.prev_item(), Some(&1)); + assert_eq!(cursor.next_item(), None); + assert_eq!(cursor.start().sum, 1); + + cursor.prev(); + assert_eq!(cursor.item(), Some(&1)); + assert_eq!(cursor.prev_item(), None); + assert_eq!(cursor.next_item(), None); + assert_eq!(cursor.start().sum, 0); + + let mut cursor = tree.cursor::(()); + assert_eq!(cursor.slice(&Count(1), Bias::Right).items(()), [1]); + assert_eq!(cursor.item(), None); + assert_eq!(cursor.prev_item(), Some(&1)); + assert_eq!(cursor.next_item(), None); + assert_eq!(cursor.start().sum, 1); + + cursor.seek(&Count(0), Bias::Right); + assert_eq!( + cursor + .slice(&tree.extent::(()), Bias::Right) + .items(()), + [1] + ); + assert_eq!(cursor.item(), None); + assert_eq!(cursor.prev_item(), Some(&1)); + assert_eq!(cursor.next_item(), None); + assert_eq!(cursor.start().sum, 1); + + // Multiple-element tree + let mut tree = SumTree::default(); + tree.extend(vec![1, 2, 3, 4, 5, 6], ()); + let mut cursor = tree.cursor::(()); + + assert_eq!(cursor.slice(&Count(2), Bias::Right).items(()), [1, 2]); + assert_eq!(cursor.item(), Some(&3)); + assert_eq!(cursor.prev_item(), Some(&2)); + assert_eq!(cursor.next_item(), Some(&4)); + assert_eq!(cursor.start().sum, 3); + + cursor.next(); + assert_eq!(cursor.item(), Some(&4)); + assert_eq!(cursor.prev_item(), Some(&3)); + assert_eq!(cursor.next_item(), Some(&5)); + assert_eq!(cursor.start().sum, 6); + + cursor.next(); + assert_eq!(cursor.item(), Some(&5)); + assert_eq!(cursor.prev_item(), Some(&4)); + assert_eq!(cursor.next_item(), Some(&6)); + assert_eq!(cursor.start().sum, 10); + + cursor.next(); + assert_eq!(cursor.item(), Some(&6)); + assert_eq!(cursor.prev_item(), Some(&5)); + assert_eq!(cursor.next_item(), None); + assert_eq!(cursor.start().sum, 15); + + cursor.next(); + cursor.next(); + assert_eq!(cursor.item(), None); + assert_eq!(cursor.prev_item(), Some(&6)); + assert_eq!(cursor.next_item(), None); + assert_eq!(cursor.start().sum, 21); + + cursor.prev(); + assert_eq!(cursor.item(), Some(&6)); + assert_eq!(cursor.prev_item(), Some(&5)); + assert_eq!(cursor.next_item(), None); + assert_eq!(cursor.start().sum, 15); + + cursor.prev(); + assert_eq!(cursor.item(), Some(&5)); + assert_eq!(cursor.prev_item(), Some(&4)); + assert_eq!(cursor.next_item(), Some(&6)); + assert_eq!(cursor.start().sum, 10); + + cursor.prev(); + assert_eq!(cursor.item(), Some(&4)); + assert_eq!(cursor.prev_item(), Some(&3)); + assert_eq!(cursor.next_item(), Some(&5)); + assert_eq!(cursor.start().sum, 6); + + cursor.prev(); + assert_eq!(cursor.item(), Some(&3)); + assert_eq!(cursor.prev_item(), Some(&2)); + assert_eq!(cursor.next_item(), Some(&4)); + assert_eq!(cursor.start().sum, 3); + + cursor.prev(); + assert_eq!(cursor.item(), Some(&2)); + assert_eq!(cursor.prev_item(), Some(&1)); + assert_eq!(cursor.next_item(), Some(&3)); + assert_eq!(cursor.start().sum, 1); + + cursor.prev(); + assert_eq!(cursor.item(), Some(&1)); + assert_eq!(cursor.prev_item(), None); + assert_eq!(cursor.next_item(), Some(&2)); + assert_eq!(cursor.start().sum, 0); + + cursor.prev(); + assert_eq!(cursor.item(), None); + assert_eq!(cursor.prev_item(), None); + assert_eq!(cursor.next_item(), Some(&1)); + assert_eq!(cursor.start().sum, 0); + + cursor.next(); + assert_eq!(cursor.item(), Some(&1)); + assert_eq!(cursor.prev_item(), None); + assert_eq!(cursor.next_item(), Some(&2)); + assert_eq!(cursor.start().sum, 0); + + let mut cursor = tree.cursor::(()); + assert_eq!( + cursor + .slice(&tree.extent::(()), Bias::Right) + .items(()), + tree.items(()) + ); + assert_eq!(cursor.item(), None); + assert_eq!(cursor.prev_item(), Some(&6)); + assert_eq!(cursor.next_item(), None); + assert_eq!(cursor.start().sum, 21); + + cursor.seek(&Count(3), Bias::Right); + assert_eq!( + cursor + .slice(&tree.extent::(()), Bias::Right) + .items(()), + [4, 5, 6] + ); + assert_eq!(cursor.item(), None); + assert_eq!(cursor.prev_item(), Some(&6)); + assert_eq!(cursor.next_item(), None); + assert_eq!(cursor.start().sum, 21); + + // Seeking can bias left or right + cursor.seek(&Count(1), Bias::Left); + assert_eq!(cursor.item(), Some(&1)); + cursor.seek(&Count(1), Bias::Right); + assert_eq!(cursor.item(), Some(&2)); + + // Slicing without resetting starts from where the cursor is parked at. + cursor.seek(&Count(1), Bias::Right); + assert_eq!(cursor.slice(&Count(3), Bias::Right).items(()), vec![2, 3]); + assert_eq!(cursor.slice(&Count(6), Bias::Left).items(()), vec![4, 5]); + assert_eq!(cursor.slice(&Count(6), Bias::Right).items(()), vec![6]); + } + + #[test] + fn test_edit() { + let mut tree = SumTree::::default(); + + let removed = tree.edit(vec![Edit::Insert(1), Edit::Insert(2), Edit::Insert(0)], ()); + assert_eq!(tree.items(()), vec![0, 1, 2]); + assert_eq!(removed, Vec::::new()); + assert_eq!(tree.get(&0, ()), Some(&0)); + assert_eq!(tree.get(&1, ()), Some(&1)); + assert_eq!(tree.get(&2, ()), Some(&2)); + assert_eq!(tree.get(&4, ()), None); + + let removed = tree.edit(vec![Edit::Insert(2), Edit::Insert(4), Edit::Remove(0)], ()); + assert_eq!(tree.items(()), vec![1, 2, 4]); + assert_eq!(removed, vec![0, 2]); + assert_eq!(tree.get(&0, ()), None); + assert_eq!(tree.get(&1, ()), Some(&1)); + assert_eq!(tree.get(&2, ()), Some(&2)); + assert_eq!(tree.get(&4, ()), Some(&4)); + } + + #[test] + fn test_from_iter() { + assert_eq!( + SumTree::from_iter(0..100, ()).items(()), + (0..100).collect::>() + ); + + // Ensure `from_iter` works correctly when the given iterator restarts + // after calling `next` if `None` was already returned. + let mut ix = 0; + let iterator = std::iter::from_fn(|| { + ix = (ix + 1) % 2; + if ix == 1 { Some(1) } else { None } + }); + assert_eq!(SumTree::from_iter(iterator, ()).items(()), vec![1]); + } + + #[derive(Clone, Default, Debug)] + pub struct IntegersSummary { + count: usize, + sum: usize, + contains_even: bool, + max: u8, + } + + #[derive(Ord, PartialOrd, Default, Eq, PartialEq, Clone, Debug)] + struct Count(usize); + + #[derive(Ord, PartialOrd, Default, Eq, PartialEq, Clone, Debug)] + struct Sum(usize); + + impl Item for u8 { + type Summary = IntegersSummary; + + fn summary(&self, _cx: ()) -> Self::Summary { + IntegersSummary { + count: 1, + sum: *self as usize, + contains_even: (*self & 1) == 0, + max: *self, + } + } + } + + impl KeyedItem for u8 { + type Key = u8; + + fn key(&self) -> Self::Key { + *self + } + } + + impl ContextLessSummary for IntegersSummary { + fn zero() -> Self { + Default::default() + } + + fn add_summary(&mut self, other: &Self) { + self.count += other.count; + self.sum += other.sum; + self.contains_even |= other.contains_even; + self.max = cmp::max(self.max, other.max); + } + } + + impl Dimension<'_, IntegersSummary> for u8 { + fn zero(_cx: ()) -> Self { + Default::default() + } + + fn add_summary(&mut self, summary: &IntegersSummary, _: ()) { + *self = summary.max; + } + } + + impl Dimension<'_, IntegersSummary> for Count { + fn zero(_cx: ()) -> Self { + Default::default() + } + + fn add_summary(&mut self, summary: &IntegersSummary, _: ()) { + self.0 += summary.count; + } + } + + impl SeekTarget<'_, IntegersSummary, IntegersSummary> for Count { + fn cmp(&self, cursor_location: &IntegersSummary, _: ()) -> Ordering { + self.0.cmp(&cursor_location.count) + } + } + + impl Dimension<'_, IntegersSummary> for Sum { + fn zero(_cx: ()) -> Self { + Default::default() + } + + fn add_summary(&mut self, summary: &IntegersSummary, _: ()) { + self.0 += summary.sum; + } + } +} diff --git a/third_party/gpui_support/zed-sum-tree/src/tree_map.rs b/third_party/gpui_support/zed-sum-tree/src/tree_map.rs new file mode 100644 index 0000000..6263040 --- /dev/null +++ b/third_party/gpui_support/zed-sum-tree/src/tree_map.rs @@ -0,0 +1,520 @@ +use std::{cmp::Ordering, fmt::Debug}; + +use crate::{Bias, ContextLessSummary, Dimension, Edit, Item, KeyedItem, SeekTarget, SumTree}; + +/// A cheaply-cloneable ordered map based on a [SumTree](crate::SumTree). +#[derive(Clone, PartialEq, Eq)] +pub struct TreeMap(SumTree>) +where + K: Clone + Ord, + V: Clone; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct MapEntry { + key: K, + value: V, +} + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub struct MapKey(Option); + +impl Default for MapKey { + fn default() -> Self { + Self(None) + } +} + +#[derive(Clone, Debug)] +pub struct MapKeyRef<'a, K>(Option<&'a K>); + +impl Default for MapKeyRef<'_, K> { + fn default() -> Self { + Self(None) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TreeSet(TreeMap) +where + K: Clone + Ord; + +impl TreeMap { + pub fn from_ordered_entries(entries: impl IntoIterator) -> Self { + let tree = SumTree::from_iter( + entries + .into_iter() + .map(|(key, value)| MapEntry { key, value }), + (), + ); + Self(tree) + } + + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + + pub fn get(&self, key: &K) -> Option<&V> { + let mut cursor = self.0.cursor::>(()); + cursor.seek(&MapKeyRef(Some(key)), Bias::Left); + if let Some(item) = cursor.item() { + if Some(key) == item.key().0.as_ref() { + Some(&item.value) + } else { + None + } + } else { + None + } + } + + pub fn insert(&mut self, key: K, value: V) { + self.0.insert_or_replace(MapEntry { key, value }, ()); + } + + pub fn extend(&mut self, iter: impl IntoIterator) { + let edits: Vec<_> = iter + .into_iter() + .map(|(key, value)| Edit::Insert(MapEntry { key, value })) + .collect(); + self.0.edit(edits, ()); + } + + pub fn clear(&mut self) { + self.0 = SumTree::default(); + } + + pub fn remove(&mut self, key: &K) -> Option { + let mut removed = None; + let mut cursor = self.0.cursor::>(()); + let key = MapKeyRef(Some(key)); + let mut new_tree = cursor.slice(&key, Bias::Left); + if key.cmp(&cursor.end(), ()) == Ordering::Equal { + removed = Some(cursor.item().unwrap().value.clone()); + cursor.next(); + } + new_tree.append(cursor.suffix(), ()); + drop(cursor); + self.0 = new_tree; + removed + } + + pub fn remove_range(&mut self, start: &impl MapSeekTarget, end: &impl MapSeekTarget) { + let start = MapSeekTargetAdaptor(start); + let end = MapSeekTargetAdaptor(end); + let mut cursor = self.0.cursor::>(()); + let mut new_tree = cursor.slice(&start, Bias::Left); + cursor.seek(&end, Bias::Left); + new_tree.append(cursor.suffix(), ()); + drop(cursor); + self.0 = new_tree; + } + + /// Returns the key-value pair with the greatest key less than or equal to the given key. + pub fn closest(&self, key: &K) -> Option<(&K, &V)> { + let mut cursor = self.0.cursor::>(()); + let key = MapKeyRef(Some(key)); + cursor.seek(&key, Bias::Right); + cursor.prev(); + cursor.item().map(|item| (&item.key, &item.value)) + } + + pub fn iter_from<'a>(&'a self, from: &K) -> impl Iterator + 'a { + let mut cursor = self.0.cursor::>(()); + let from_key = MapKeyRef(Some(from)); + cursor.seek(&from_key, Bias::Left); + + cursor.map(|map_entry| (&map_entry.key, &map_entry.value)) + } + + pub fn update(&mut self, key: &K, f: F) -> Option + where + F: FnOnce(&mut V) -> T, + { + let mut cursor = self.0.cursor::>(()); + let key = MapKeyRef(Some(key)); + let mut new_tree = cursor.slice(&key, Bias::Left); + let mut result = None; + if key.cmp(&cursor.end(), ()) == Ordering::Equal { + let mut updated = cursor.item().unwrap().clone(); + result = Some(f(&mut updated.value)); + new_tree.push(updated, ()); + cursor.next(); + } + new_tree.append(cursor.suffix(), ()); + drop(cursor); + self.0 = new_tree; + result + } + + pub fn retain bool>(&mut self, mut predicate: F) { + let mut new_map = SumTree::>::default(); + + let mut cursor = self.0.cursor::>(()); + cursor.next(); + while let Some(item) = cursor.item() { + if predicate(&item.key, &item.value) { + new_map.push(item.clone(), ()); + } + cursor.next(); + } + drop(cursor); + + self.0 = new_map; + } + + pub fn iter(&self) -> impl Iterator + '_ { + self.0.iter().map(|entry| (&entry.key, &entry.value)) + } + + pub fn values(&self) -> impl Iterator + '_ { + self.0.iter().map(|entry| &entry.value) + } + + pub fn first(&self) -> Option<(&K, &V)> { + self.0.first().map(|entry| (&entry.key, &entry.value)) + } + + pub fn last(&self) -> Option<(&K, &V)> { + self.0.last().map(|entry| (&entry.key, &entry.value)) + } + + pub fn insert_tree(&mut self, other: TreeMap) { + let edits = other + .iter() + .map(|(key, value)| { + Edit::Insert(MapEntry { + key: key.to_owned(), + value: value.to_owned(), + }) + }) + .collect(); + + self.0.edit(edits, ()); + } +} + +impl Debug for TreeMap +where + K: Clone + Debug + Ord, + V: Clone + Debug, +{ + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_map().entries(self.iter()).finish() + } +} + +#[derive(Debug)] +struct MapSeekTargetAdaptor<'a, T>(&'a T); + +impl<'a, K: Clone + Ord, T: MapSeekTarget> SeekTarget<'a, MapKey, MapKeyRef<'a, K>> + for MapSeekTargetAdaptor<'_, T> +{ + fn cmp(&self, cursor_location: &MapKeyRef, _: ()) -> Ordering { + if let Some(key) = &cursor_location.0 { + MapSeekTarget::cmp_cursor(self.0, key) + } else { + Ordering::Greater + } + } +} + +pub trait MapSeekTarget { + fn cmp_cursor(&self, cursor_location: &K) -> Ordering; +} + +impl MapSeekTarget for K { + fn cmp_cursor(&self, cursor_location: &K) -> Ordering { + self.cmp(cursor_location) + } +} + +impl Default for TreeMap +where + K: Clone + Ord, + V: Clone, +{ + fn default() -> Self { + Self(Default::default()) + } +} + +impl Item for MapEntry +where + K: Clone + Ord, + V: Clone, +{ + type Summary = MapKey; + + fn summary(&self, _cx: ()) -> Self::Summary { + self.key() + } +} + +impl KeyedItem for MapEntry +where + K: Clone + Ord, + V: Clone, +{ + type Key = MapKey; + + fn key(&self) -> Self::Key { + MapKey(Some(self.key.clone())) + } +} + +impl ContextLessSummary for MapKey +where + K: Clone, +{ + fn zero() -> Self { + Default::default() + } + + fn add_summary(&mut self, summary: &Self) { + *self = summary.clone() + } +} + +impl<'a, K> Dimension<'a, MapKey> for MapKeyRef<'a, K> +where + K: Clone + Ord, +{ + fn zero(_cx: ()) -> Self { + Default::default() + } + + fn add_summary(&mut self, summary: &'a MapKey, _: ()) { + self.0 = summary.0.as_ref(); + } +} + +impl<'a, K> SeekTarget<'a, MapKey, MapKeyRef<'a, K>> for MapKeyRef<'_, K> +where + K: Clone + Ord, +{ + fn cmp(&self, cursor_location: &MapKeyRef, _: ()) -> Ordering { + Ord::cmp(&self.0, &cursor_location.0) + } +} + +impl Default for TreeSet +where + K: Clone + Ord, +{ + fn default() -> Self { + Self(Default::default()) + } +} + +impl TreeSet +where + K: Clone + Ord, +{ + pub fn from_ordered_entries(entries: impl IntoIterator) -> Self { + Self(TreeMap::from_ordered_entries( + entries.into_iter().map(|key| (key, ())), + )) + } + + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + + pub fn insert(&mut self, key: K) { + self.0.insert(key, ()); + } + + pub fn remove(&mut self, key: &K) -> bool { + self.0.remove(key).is_some() + } + + pub fn extend(&mut self, iter: impl IntoIterator) { + self.0.extend(iter.into_iter().map(|key| (key, ()))); + } + + pub fn contains(&self, key: &K) -> bool { + self.0.get(key).is_some() + } + + pub fn iter(&self) -> impl Iterator + '_ { + self.0.iter().map(|(k, _)| k) + } + + pub fn iter_from<'a>(&'a self, key: &K) -> impl Iterator + 'a { + self.0.iter_from(key).map(move |(k, _)| k) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_basic() { + let mut map = TreeMap::default(); + assert_eq!(map.iter().collect::>(), vec![]); + + map.insert(3, "c"); + assert_eq!(map.get(&3), Some(&"c")); + assert_eq!(map.iter().collect::>(), vec![(&3, &"c")]); + + map.insert(1, "a"); + assert_eq!(map.get(&1), Some(&"a")); + assert_eq!(map.iter().collect::>(), vec![(&1, &"a"), (&3, &"c")]); + + map.insert(2, "b"); + assert_eq!(map.get(&2), Some(&"b")); + assert_eq!(map.get(&1), Some(&"a")); + assert_eq!(map.get(&3), Some(&"c")); + assert_eq!( + map.iter().collect::>(), + vec![(&1, &"a"), (&2, &"b"), (&3, &"c")] + ); + + assert_eq!(map.closest(&0), None); + assert_eq!(map.closest(&1), Some((&1, &"a"))); + assert_eq!(map.closest(&10), Some((&3, &"c"))); + + map.remove(&2); + assert_eq!(map.get(&2), None); + assert_eq!(map.iter().collect::>(), vec![(&1, &"a"), (&3, &"c")]); + + assert_eq!(map.closest(&2), Some((&1, &"a"))); + + map.remove(&3); + assert_eq!(map.get(&3), None); + assert_eq!(map.iter().collect::>(), vec![(&1, &"a")]); + + map.remove(&1); + assert_eq!(map.get(&1), None); + assert_eq!(map.iter().collect::>(), vec![]); + + map.insert(4, "d"); + map.insert(5, "e"); + map.insert(6, "f"); + map.retain(|key, _| *key % 2 == 0); + assert_eq!(map.iter().collect::>(), vec![(&4, &"d"), (&6, &"f")]); + } + + #[test] + fn test_iter_from() { + let mut map = TreeMap::default(); + + map.insert("a", 1); + map.insert("b", 2); + map.insert("baa", 3); + map.insert("baaab", 4); + map.insert("c", 5); + + let result = map + .iter_from(&"ba") + .take_while(|(key, _)| key.starts_with("ba")) + .collect::>(); + + assert_eq!(result.len(), 2); + assert!(result.iter().any(|(k, _)| k == &&"baa")); + assert!(result.iter().any(|(k, _)| k == &&"baaab")); + + let result = map + .iter_from(&"c") + .take_while(|(key, _)| key.starts_with("c")) + .collect::>(); + + assert_eq!(result.len(), 1); + assert!(result.iter().any(|(k, _)| k == &&"c")); + } + + #[test] + fn test_insert_tree() { + let mut map = TreeMap::default(); + map.insert("a", 1); + map.insert("b", 2); + map.insert("c", 3); + + let mut other = TreeMap::default(); + other.insert("a", 2); + other.insert("b", 2); + other.insert("d", 4); + + map.insert_tree(other); + + assert_eq!(map.iter().count(), 4); + assert_eq!(map.get(&"a"), Some(&2)); + assert_eq!(map.get(&"b"), Some(&2)); + assert_eq!(map.get(&"c"), Some(&3)); + assert_eq!(map.get(&"d"), Some(&4)); + } + + #[test] + fn test_extend() { + let mut map = TreeMap::default(); + map.insert("a", 1); + map.insert("b", 2); + map.insert("c", 3); + map.extend([("a", 2), ("b", 2), ("d", 4)]); + assert_eq!(map.iter().count(), 4); + assert_eq!(map.get(&"a"), Some(&2)); + assert_eq!(map.get(&"b"), Some(&2)); + assert_eq!(map.get(&"c"), Some(&3)); + assert_eq!(map.get(&"d"), Some(&4)); + } + + #[test] + fn test_remove_between_and_path_successor() { + use std::path::{Path, PathBuf}; + + #[derive(Debug)] + pub struct PathDescendants<'a>(&'a Path); + + impl MapSeekTarget for PathDescendants<'_> { + fn cmp_cursor(&self, key: &PathBuf) -> Ordering { + if key.starts_with(self.0) { + Ordering::Greater + } else { + self.0.cmp(key) + } + } + } + + let mut map = TreeMap::default(); + + map.insert(PathBuf::from("a"), 1); + map.insert(PathBuf::from("a/a"), 1); + map.insert(PathBuf::from("b"), 2); + map.insert(PathBuf::from("b/a/a"), 3); + map.insert(PathBuf::from("b/a/a/a/b"), 4); + map.insert(PathBuf::from("c"), 5); + map.insert(PathBuf::from("c/a"), 6); + + map.remove_range( + &PathBuf::from("b/a"), + &PathDescendants(&PathBuf::from("b/a")), + ); + + assert_eq!(map.get(&PathBuf::from("a")), Some(&1)); + assert_eq!(map.get(&PathBuf::from("a/a")), Some(&1)); + assert_eq!(map.get(&PathBuf::from("b")), Some(&2)); + assert_eq!(map.get(&PathBuf::from("b/a/a")), None); + assert_eq!(map.get(&PathBuf::from("b/a/a/a/b")), None); + assert_eq!(map.get(&PathBuf::from("c")), Some(&5)); + assert_eq!(map.get(&PathBuf::from("c/a")), Some(&6)); + + map.remove_range(&PathBuf::from("c"), &PathDescendants(&PathBuf::from("c"))); + + assert_eq!(map.get(&PathBuf::from("a")), Some(&1)); + assert_eq!(map.get(&PathBuf::from("a/a")), Some(&1)); + assert_eq!(map.get(&PathBuf::from("b")), Some(&2)); + assert_eq!(map.get(&PathBuf::from("c")), None); + assert_eq!(map.get(&PathBuf::from("c/a")), None); + + map.remove_range(&PathBuf::from("a"), &PathDescendants(&PathBuf::from("a"))); + + assert_eq!(map.get(&PathBuf::from("a")), None); + assert_eq!(map.get(&PathBuf::from("a/a")), None); + assert_eq!(map.get(&PathBuf::from("b")), Some(&2)); + + map.remove_range(&PathBuf::from("b"), &PathDescendants(&PathBuf::from("b"))); + + assert_eq!(map.get(&PathBuf::from("b")), None); + } +}