diff --git a/Cargo.lock b/Cargo.lock index 4d67b484..9c820215 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -296,17 +296,6 @@ dependencies = [ "crypto-common", ] -[[package]] -name = "displaydoc" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "dunce" version = "1.0.5" @@ -441,15 +430,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" -[[package]] -name = "form_urlencoded" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" -dependencies = [ - "percent-encoding", -] - [[package]] name = "futures-core" version = "0.3.32" @@ -1428,7 +1408,6 @@ dependencies = [ "unicode-general-category", "unicode-segmentation", "unicode-width 0.1.12", - "url", ] [[package]] @@ -1520,9 +1499,9 @@ name = "helix-lsp-types" version = "0.95.1" dependencies = [ "bitflags", + "helix-stdx", "serde", "serde_json", - "url", ] [[package]] @@ -1537,10 +1516,13 @@ dependencies = [ "dunce", "etcetera", "once_cell", + "percent-encoding", "regex-automata", "regex-cursor", "ropey", "rustix 1.1.4", + "serde", + "serde_json", "tempfile", "unicode-segmentation", "which", @@ -1594,7 +1576,6 @@ dependencies = [ "tokio", "tokio-stream", "toml", - "url", ] [[package]] @@ -1664,7 +1645,6 @@ dependencies = [ "tokio", "tokio-stream", "toml", - "url", ] [[package]] @@ -1705,151 +1685,12 @@ dependencies = [ "cc", ] -[[package]] -name = "icu_collections" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526" -dependencies = [ - "displaydoc", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "icu_locid" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637" -dependencies = [ - "displaydoc", - "litemap", - "tinystr", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_locid_transform" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01d11ac35de8e40fdeda00d9e1e9d92525f3f9d887cdd7aa81d727596788b54e" -dependencies = [ - "displaydoc", - "icu_locid", - "icu_locid_transform_data", - "icu_provider", - "tinystr", - "zerovec", -] - -[[package]] -name = "icu_locid_transform_data" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdc8ff3388f852bede6b579ad4e978ab004f139284d7b28715f773507b946f6e" - -[[package]] -name = "icu_normalizer" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19ce3e0da2ec68599d193c93d088142efd7f9c5d6fc9b803774855747dc6a84f" -dependencies = [ - "displaydoc", - "icu_collections", - "icu_normalizer_data", - "icu_properties", - "icu_provider", - "smallvec", - "utf16_iter", - "utf8_iter", - "write16", - "zerovec", -] - -[[package]] -name = "icu_normalizer_data" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8cafbf7aa791e9b22bec55a167906f9e1215fd475cd22adfcf660e03e989516" - -[[package]] -name = "icu_properties" -version = "1.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93d6020766cfc6302c15dbbc9c8778c37e62c14427cb7f6e601d849e092aeef5" -dependencies = [ - "displaydoc", - "icu_collections", - "icu_locid_transform", - "icu_properties_data", - "icu_provider", - "tinystr", - "zerovec", -] - -[[package]] -name = "icu_properties_data" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67a8effbc3dd3e4ba1afa8ad918d5684b8868b3b26500753effea8d2eed19569" - -[[package]] -name = "icu_provider" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ed421c8a8ef78d3e2dbc98a973be2f3770cb42b606e3ab18d6237c4dfde68d9" -dependencies = [ - "displaydoc", - "icu_locid", - "icu_provider_macros", - "stable_deref_trait", - "tinystr", - "writeable", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "icu_provider_macros" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "id-arena" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" -[[package]] -name = "idna" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" -dependencies = [ - "idna_adapter", - "smallvec", - "utf8_iter", -] - -[[package]] -name = "idna_adapter" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "daca1df1c957320b2cf139ac61e7bd64fed304c5040df000a745aa1de3b4ef71" -dependencies = [ - "icu_normalizer", - "icu_properties", -] - [[package]] name = "ignore" version = "0.4.25" @@ -2027,12 +1868,6 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" -[[package]] -name = "litemap" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "643cb0b8d4fcc284004d5fd0d67ccf61dfffadb7f75e1e71bc420f4688a3a704" - [[package]] name = "lock_api" version = "0.4.14" @@ -2785,17 +2620,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "synstructure" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "tempfile" version = "3.27.0" @@ -2872,16 +2696,6 @@ dependencies = [ "num_cpus", ] -[[package]] -name = "tinystr" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f" -dependencies = [ - "displaydoc", - "zerovec", -] - [[package]] name = "tinyvec" version = "1.8.0" @@ -3086,30 +2900,6 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" -[[package]] -name = "url" -version = "2.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" -dependencies = [ - "form_urlencoded", - "idna", - "percent-encoding", - "serde", -] - -[[package]] -name = "utf16_iter" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" - -[[package]] -name = "utf8_iter" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" - [[package]] name = "uuid" version = "1.18.1" @@ -3575,18 +3365,6 @@ dependencies = [ "wasmparser", ] -[[package]] -name = "write16" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" - -[[package]] -name = "writeable" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" - [[package]] name = "xtask" version = "25.7.1" @@ -3600,30 +3378,6 @@ dependencies = [ "toml", ] -[[package]] -name = "yoke" -version = "0.7.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c5b1314b079b0930c31e3af543d8ee1757b1951ae1e1565ec704403a7240ca5" -dependencies = [ - "serde", - "stable_deref_trait", - "yoke-derive", - "zerofrom", -] - -[[package]] -name = "yoke-derive" -version = "0.7.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28cc31741b18cb6f1d5ff12f5b7523e3d6eb0852bbbad19d73905511d9849b95" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "synstructure", -] - [[package]] name = "zerocopy" version = "0.8.27" @@ -3644,49 +3398,6 @@ dependencies = [ "syn", ] -[[package]] -name = "zerofrom" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91ec111ce797d0e0784a1116d0ddcdbea84322cd79e5d5ad173daeba4f93ab55" -dependencies = [ - "zerofrom-derive", -] - -[[package]] -name = "zerofrom-derive" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ea7b4a3637ea8669cedf0f1fd5c286a17f3de97b8dd5a70a6c167a1730e63a5" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "synstructure", -] - -[[package]] -name = "zerovec" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa2b893d79df23bfb12d5461018d408ea19dfafe76c2c7ef6d4eba614f8ff079" -dependencies = [ - "yoke", - "zerofrom", - "zerovec-derive", -] - -[[package]] -name = "zerovec-derive" -version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "zlib-rs" version = "0.6.3" diff --git a/helix-core/Cargo.toml b/helix-core/Cargo.toml index e7e79ea4..0f7db5d1 100644 --- a/helix-core/Cargo.toml +++ b/helix-core/Cargo.toml @@ -38,7 +38,6 @@ arc-swap = "1" regex = "1" bitflags.workspace = true foldhash.workspace = true -url = "2.5.4" log = "0.4" anyhow = "1.0" diff --git a/helix-core/src/uri.rs b/helix-core/src/uri.rs index a8f9f307..b63cef63 100644 --- a/helix-core/src/uri.rs +++ b/helix-core/src/uri.rs @@ -16,11 +16,11 @@ pub enum Uri { } impl Uri { - // This clippy allow mirrors url::Url::from_file_path + // This clippy allow mirrors helix_stdx::Url::from_file_path #[allow(clippy::result_unit_err)] - pub fn to_url(&self) -> Result { + pub fn to_url(&self) -> Result { match self { - Uri::File(path) => url::Url::from_file_path(path), + Uri::File(path) => helix_stdx::Url::from_file_path(path), } } @@ -53,7 +53,7 @@ impl fmt::Display for Uri { #[derive(Debug)] pub struct UrlConversionError { - source: url::Url, + source: helix_stdx::Url, kind: UrlConversionErrorKind, } @@ -83,7 +83,7 @@ impl fmt::Display for UrlConversionError { impl std::error::Error for UrlConversionError {} -fn convert_url_to_uri(url: &url::Url) -> Result { +fn convert_url_to_uri(url: &helix_stdx::Url) -> Result { if url.scheme() == "file" { url.to_file_path() .map(|path| Uri::File(helix_stdx::path::normalize(path).into())) @@ -93,18 +93,18 @@ fn convert_url_to_uri(url: &url::Url) -> Result { } } -impl TryFrom for Uri { +impl TryFrom for Uri { type Error = UrlConversionError; - fn try_from(url: url::Url) -> Result { + fn try_from(url: helix_stdx::Url) -> Result { convert_url_to_uri(&url).map_err(|kind| Self::Error { source: url, kind }) } } -impl TryFrom<&url::Url> for Uri { +impl TryFrom<&helix_stdx::Url> for Uri { type Error = UrlConversionError; - fn try_from(url: &url::Url) -> Result { + fn try_from(url: &helix_stdx::Url) -> Result { convert_url_to_uri(url).map_err(|kind| Self::Error { source: url.clone(), kind, @@ -115,7 +115,7 @@ impl TryFrom<&url::Url> for Uri { #[cfg(test)] mod test { use super::*; - use url::Url; + use helix_stdx::Url; #[test] fn unknown_scheme() { diff --git a/helix-lsp-types/Cargo.toml b/helix-lsp-types/Cargo.toml index 27a38adb..dcd8d341 100644 --- a/helix-lsp-types/Cargo.toml +++ b/helix-lsp-types/Cargo.toml @@ -24,7 +24,7 @@ license = "MIT" bitflags.workspace = true serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.150" -url = {version = "2.5.4", features = ["serde"]} +helix-stdx = { path = "../helix-stdx" } [features] default = [] diff --git a/helix-lsp-types/README.md b/helix-lsp-types/README.md index 01803be1..716a4b7c 100644 --- a/helix-lsp-types/README.md +++ b/helix-lsp-types/README.md @@ -1,3 +1,5 @@ # Helix's `lsp-types` -This is a fork of the [`lsp-types`](https://crates.io/crates/lsp-types) crate ([`gluon-lang/lsp-types`](https://github.com/gluon-lang/lsp-types)) taken at version v0.95.1 (commit [3e6daee](https://github.com/gluon-lang/lsp-types/commit/3e6daee771d14db4094a554b8d03e29c310dfcbe)). This fork focuses usability improvements that make the types easier to work with for the Helix codebase. For example the URL type - the `uri` crate at this version of `lsp-types` - will be replaced with a wrapper around a string. +This is a fork of the [`lsp-types`](https://crates.io/crates/lsp-types) crate ([`gluon-lang/lsp-types`](https://github.com/gluon-lang/lsp-types)) taken at version v0.95.1 (commit [3e6daee](https://github.com/gluon-lang/lsp-types/commit/3e6daee771d14db4094a554b8d03e29c310dfcbe)). This fork focuses usability improvements that make the types easier to work with for the Helix codebase. + +The URL type has been replaced with a newtype wrapper of a `String`. The `lsp-types` crate at the forked version used [`url::Url`](https://docs.rs/url/2.5.0/url/struct.Url.html) which provides conveniences for using URLs according to [the WHATWG URL spec](https://url.spec.whatwg.org). Helix supports a subset of valid URLs, namely the `file://` scheme, so a wrapper around a normal `String` is sufficient. Plus the LSP spec requires URLs to be in [RFC3986](https://tools.ietf.org/html/rfc3986) format instead. diff --git a/helix-lsp-types/src/lib.rs b/helix-lsp-types/src/lib.rs index a1a87e1b..afba1f2c 100644 --- a/helix-lsp-types/src/lib.rs +++ b/helix-lsp-types/src/lib.rs @@ -21,9 +21,9 @@ use bitflags::bitflags; use std::{collections::HashMap, fmt::Debug}; +pub use helix_stdx::Url; use serde::{de, de::Error as Error_, Deserialize, Serialize}; use serde_json::Value; -pub use url::Url; // Large enough to contain any enumeration name defined in this crate type PascalCaseBuf = [u8; 32]; @@ -2856,7 +2856,9 @@ mod tests { document_changes: None, ..Default::default() }, - r#"{"changes":{"file://test/":[]}}"#, + // `Url` stores the URI verbatim (RFC3986), unlike `url::Url` which + // would WHATWG-normalize this host-only file URL to `file://test/`. + r#"{"changes":{"file://test":[]}}"#, ); } diff --git a/helix-lsp/src/client.rs b/helix-lsp/src/client.rs index ad19efae..0d07d243 100644 --- a/helix-lsp/src/client.rs +++ b/helix-lsp/src/client.rs @@ -43,8 +43,9 @@ use tokio::{ fn workspace_for_uri(uri: lsp::Url) -> WorkspaceFolder { lsp::WorkspaceFolder { name: uri - .path_segments() - .and_then(|mut segments| segments.next_back()) + .path() + .rsplit('/') + .find(|segment| !segment.is_empty()) .map(|basename| basename.to_string()) .unwrap_or_default(), uri, diff --git a/helix-stdx/Cargo.toml b/helix-stdx/Cargo.toml index 55586855..8a862930 100644 --- a/helix-stdx/Cargo.toml +++ b/helix-stdx/Cargo.toml @@ -21,6 +21,8 @@ bitflags.workspace = true once_cell = "1.21" regex-automata = "0.4.14" unicode-segmentation.workspace = true +serde = { version = "1.0", features = ["derive"] } +percent-encoding = "2.3" [target.'cfg(windows)'.dependencies] windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Security", "Win32_Security_Authorization", "Win32_Storage_FileSystem", "Win32_System_Threading"] } @@ -30,3 +32,4 @@ rustix = { version = "1.1", features = ["fs", "thread"] } [dev-dependencies] tempfile.workspace = true +serde_json = "1.0" diff --git a/helix-stdx/src/lib.rs b/helix-stdx/src/lib.rs index e371f392..aa218035 100644 --- a/helix-stdx/src/lib.rs +++ b/helix-stdx/src/lib.rs @@ -6,5 +6,7 @@ pub mod faccess; pub mod path; pub mod range; pub mod rope; +pub mod uri; pub use range::Range; +pub use uri::Url; diff --git a/helix-stdx/src/uri.rs b/helix-stdx/src/uri.rs new file mode 100644 index 00000000..8d136637 --- /dev/null +++ b/helix-stdx/src/uri.rs @@ -0,0 +1,343 @@ +//! A minimal RFC3986 URI type backed by a string. +//! +//! We only care to convert between `file://` URIs and `PathBuf`, so we don't need a fully +//! featured URL/URI crate. Also, LSP uses RFC3986 rather than WHATWG, and the two have different +//! percent encoding rules. Encoding follows RFC3986 (percent-encode everything outside `pchar`), +//! not the WHATWG URL rules the `url` crate implements. Some language servers are strict about +//! this (e.g. Deno): they reject unescaped `[`/`]` in paths (valid in WHATWG but not RFC3986). + +use std::fmt; +use std::path::{Path, PathBuf}; +use std::str::FromStr; + +use percent_encoding::{percent_decode, percent_encode, AsciiSet, NON_ALPHANUMERIC}; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +/// The set of bytes percent-encoded in a path. RFC3986 allows `pchar` +/// (`unreserved` / `sub-delims` / `:` / `@`) plus `/` as the separator +/// unescaped; everything else (and every non-ASCII byte) is encoded. +const PATH: &AsciiSet = &NON_ALPHANUMERIC + // unreserved: ALPHA / DIGIT (already kept) / "-" / "." / "_" / "~" + .remove(b'-') + .remove(b'.') + .remove(b'_') + .remove(b'~') + // sub-delims + .remove(b'!') + .remove(b'$') + .remove(b'&') + .remove(b'\'') + .remove(b'(') + .remove(b')') + .remove(b'*') + .remove(b'+') + .remove(b',') + .remove(b';') + .remove(b'=') + // pchar extras and the path separator + .remove(b':') + .remove(b'@') + .remove(b'/'); + +/// An RFC3986 URI. +/// +/// The URI is stored verbatim and only interpreted when a `file://` path is actually needed. +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct Url(String); + +/// Error returned when a string is not a valid absolute URI. +#[derive(Debug, PartialEq, Eq)] +pub struct ParseError; + +impl fmt::Display for ParseError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("invalid URI: relative URL without a base") + } +} + +impl std::error::Error for ParseError {} + +impl Url { + /// Parse an absolute URI. Mirrors `url::Url::parse`'s rejection of relative + /// references: the input must begin with a valid scheme (`ALPHA *( ALPHA / + /// DIGIT / "+" / "-" / "." ) ":"`). + pub fn parse(input: &str) -> Result { + let colon = input.find(':').ok_or(ParseError)?; + let scheme = &input[..colon]; + let mut bytes = scheme.bytes(); + let valid = bytes.next().is_some_and(|b| b.is_ascii_alphabetic()) + && bytes.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'+' | b'-' | b'.')); + if valid { + Ok(Url(input.to_string())) + } else { + Err(ParseError) + } + } + + /// The full URI as a string slice. + pub fn as_str(&self) -> &str { + &self.0 + } + + /// The scheme (the part before the first `:`), e.g. `file`. + pub fn scheme(&self) -> &str { + match self.0.find(':') { + Some(i) => &self.0[..i], + None => "", + } + } + + /// The (still percent-encoded) path component, matching `url::Url::path`. + pub fn path(&self) -> &str { + let after_scheme = match self.0.find(':') { + Some(i) => &self.0[i + 1..], + None => self.0.as_str(), + }; + // Skip an `//authority` component if present. + let after_authority = match after_scheme.strip_prefix("//") { + Some(rest) => match rest.find('/') { + Some(i) => &rest[i..], + None => "", + }, + None => after_scheme, + }; + let end = after_authority + .find(['?', '#']) + .unwrap_or(after_authority.len()); + &after_authority[..end] + } + + /// Build a `file://` URI from an absolute filesystem path. + #[allow(clippy::result_unit_err)] + pub fn from_file_path>(path: P) -> Result { + let path = path.as_ref(); + if !path.is_absolute() { + return Err(()); + } + let mut serialization = String::from("file://"); + serialize_path(&mut serialization, path)?; + Ok(Url(serialization)) + } + + /// Like [`Url::from_file_path`], but ensures a trailing slash so the URI + /// denotes a directory. + #[allow(clippy::result_unit_err)] + pub fn from_directory_path>(path: P) -> Result { + let mut url = Url::from_file_path(path)?; + if !url.0.ends_with('/') { + url.0.push('/'); + } + Ok(url) + } + + /// Convert a `file://` URI back to a filesystem path. + #[allow(clippy::result_unit_err)] + pub fn to_file_path(&self) -> Result { + if self.scheme() != "file" { + return Err(()); + } + let rest = self.0["file:".len()..].strip_prefix("//").ok_or(())?; + let (authority, path) = match rest.find('/') { + Some(i) => (&rest[..i], &rest[i..]), + None => (rest, ""), + }; + let local_host = authority.is_empty() || authority.eq_ignore_ascii_case("localhost"); + + #[cfg(not(windows))] + { + use std::ffi::OsStr; + use std::os::unix::ffi::OsStrExt; + if !local_host { + return Err(()); + } + let bytes = percent_decode(path.as_bytes()).collect::>(); + if bytes.is_empty() { + return Err(()); + } + Ok(PathBuf::from(OsStr::from_bytes(&bytes))) + } + #[cfg(windows)] + { + let decoded = percent_decode(path.as_bytes()) + .decode_utf8() + .map_err(|_| ())?; + if local_host { + // `/C:/dir/file` -> `C:\dir\file` + let path = decoded.strip_prefix('/').unwrap_or(&decoded); + if path.is_empty() { + return Err(()); + } + Ok(PathBuf::from(path.replace('/', "\\"))) + } else { + // UNC: `file://server/share/...` -> `\\server\share\...` + Ok(PathBuf::from(format!( + "\\\\{}{}", + authority, + decoded.replace('/', "\\") + ))) + } + } + } +} + +#[cfg(not(windows))] +fn serialize_path(out: &mut String, path: &Path) -> Result<(), ()> { + use std::os::unix::ffi::OsStrExt; + // The path is absolute, so it begins with `/`; percent-encode it while + // preserving the `/` separators (they are excluded from `PATH`). + out.extend(percent_encode(path.as_os_str().as_bytes(), PATH)); + Ok(()) +} + +#[cfg(windows)] +fn serialize_path(out: &mut String, path: &Path) -> Result<(), ()> { + use std::path::{Component, Prefix}; + let mut components = path.components(); + match components.next() { + Some(Component::Prefix(prefix)) => match prefix.kind() { + Prefix::Disk(_) | Prefix::VerbatimDisk(_) => { + // `C:` -> `/C:` + out.push('/'); + out.push_str(&prefix.as_os_str().to_string_lossy()); + } + Prefix::UNC(server, share) | Prefix::VerbatimUNC(server, share) => { + // `\\server\share` -> `//server/share` (authority + first seg) + out.pop(); // drop one `/` from the `file://` we were appended to + out.push_str(&server.to_string_lossy()); + out.push('/'); + out.extend(percent_encode(share.to_string_lossy().as_bytes(), PATH)); + } + _ => return Err(()), + }, + _ => return Err(()), + } + for component in components { + match component { + Component::RootDir => {} + Component::Normal(seg) => { + out.push('/'); + out.extend(percent_encode(seg.to_string_lossy().as_bytes(), PATH)); + } + Component::CurDir => out.push_str("/."), + Component::ParentDir => out.push_str("/.."), + Component::Prefix(_) => return Err(()), + } + } + Ok(()) +} + +impl FromStr for Url { + type Err = ParseError; + + fn from_str(s: &str) -> Result { + Url::parse(s) + } +} + +impl fmt::Display for Url { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl fmt::Debug for Url { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl AsRef for Url { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl Serialize for Url { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(&self.0) + } +} + +impl<'de> Deserialize<'de> for Url { + fn deserialize>(deserializer: D) -> Result { + // Store the URI as-is. Path conversion is lazy. + Ok(Url(String::deserialize(deserializer)?)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn scheme_and_path() { + let url = Url::parse("file:///home/user/main.rs").unwrap(); + assert_eq!(url.scheme(), "file"); + assert_eq!(url.path(), "/home/user/main.rs"); + + let url = Url::parse("csharp:/metadata/foo/Baz.cs").unwrap(); + assert_eq!(url.scheme(), "csharp"); + assert_eq!(url.path(), "/metadata/foo/Baz.cs"); + + // authority present + let url = Url::parse("file://host/path?q#frag").unwrap(); + assert_eq!(url.path(), "/path"); + } + + #[test] + fn parse_rejects_relative() { + assert!(Url::parse("src/main.rs").is_err()); + assert!(Url::parse("just text").is_err()); + assert!(Url::parse("https://example.com").is_ok()); + } + + #[cfg(not(windows))] + #[test] + fn file_path_round_trip() { + for path in [ + "/home/user/main.rs", + "/tmp/a b.txt", // space + "/tmp/[test]/x.ts", // brackets (Deno RFC3986 fix) + "/tmp/c#/Program.cs", // '#' + "/home/üser/café.txt", // non-ASCII + "/weird/100%/qu?ery&x=1", // '%', '?', '&' + ] { + let url = Url::from_file_path(path).unwrap(); + assert_eq!(url.to_file_path().unwrap(), PathBuf::from(path), "{}", url); + } + } + + #[cfg(not(windows))] + #[test] + fn rfc3986_encoding() { + let url = Url::from_file_path("/tmp/[test]/a b.ts").unwrap(); + // brackets and space are percent-encoded; '/' and unreserved are not + assert_eq!(url.as_str(), "file:///tmp/%5Btest%5D/a%20b.ts"); + } + + #[cfg(not(windows))] + #[test] + fn directory_path_has_trailing_slash() { + let url = Url::from_directory_path("/home/user").unwrap(); + assert_eq!(url.as_str(), "file:///home/user/"); + } + + #[test] + fn from_file_path_rejects_relative() { + assert!(Url::from_file_path("relative/path").is_err()); + } + + #[test] + fn non_file_scheme_has_no_path() { + assert!(Url::parse("untitled:foo").unwrap().to_file_path().is_err()); + } + + #[test] + fn serde_is_opaque() { + let url = Url::parse("file:///a/b.rs").unwrap(); + let json = serde_json::to_string(&url).unwrap(); + assert_eq!(json, "\"file:///a/b.rs\""); + let back: Url = serde_json::from_str(&json).unwrap(); + assert_eq!(url, back); + } +} diff --git a/helix-term/Cargo.toml b/helix-term/Cargo.toml index 030bf090..5a8bc210 100644 --- a/helix-term/Cargo.toml +++ b/helix-term/Cargo.toml @@ -82,7 +82,6 @@ thiserror.workspace = true # opening URLs open = "5.3.5" -url = "2.5.4" # config toml.workspace = true diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index 58233bda..9e8cae00 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -86,9 +86,9 @@ use std::{ path::{Path, PathBuf}, }; +use helix_stdx::Url; use once_cell::sync::Lazy; use serde::de::{self, Deserialize, Deserializer}; -use url::Url; use grep_regex::RegexMatcherBuilder; use grep_searcher::{sinks, BinaryDetection, SearcherBuilder}; diff --git a/helix-term/src/handlers/completion/path.rs b/helix-term/src/handlers/completion/path.rs index c2ffa5ef..17a35671 100644 --- a/helix-term/src/handlers/completion/path.rs +++ b/helix-term/src/handlers/completion/path.rs @@ -9,8 +9,8 @@ use std::{ use helix_core::{self as core, completion::CompletionProvider, Selection, Transaction}; use helix_event::TaskHandle; use helix_stdx::path::{self, canonicalize, fold_home_dir, get_path_suffix}; +use helix_stdx::Url; use helix_view::{document::SavePoint, handlers::completion::ResponseContext, Document}; -use url::Url; use crate::handlers::completion::{item::CompletionResponse, CompletionItem, CompletionItems}; diff --git a/helix-term/src/lib.rs b/helix-term/src/lib.rs index 6ddf41f1..c7b0d5ee 100644 --- a/helix-term/src/lib.rs +++ b/helix-term/src/lib.rs @@ -20,8 +20,8 @@ use std::path::Path; use futures_util::Future; mod handlers; +use helix_stdx::Url; use ignore::DirEntry; -use url::Url; #[cfg(windows)] fn true_color() -> bool { diff --git a/helix-view/Cargo.toml b/helix-view/Cargo.toml index eb958747..e5a29a04 100644 --- a/helix-view/Cargo.toml +++ b/helix-view/Cargo.toml @@ -32,7 +32,6 @@ tempfile.workspace = true # Conversion traits once_cell = "1.21" -url = "2.5.4" arc-swap.workspace = true diff --git a/helix-view/src/document.rs b/helix-view/src/document.rs index 3b127848..e1813cd0 100644 --- a/helix-view/src/document.rs +++ b/helix-view/src/document.rs @@ -707,7 +707,7 @@ where } use helix_lsp::{lsp, Client, LanguageServerId, LanguageServerName}; -use url::Url; +use helix_stdx::Url; impl Document { pub fn from(