diff --git a/crates/ide-extension/src/capability.rs b/crates/ide-extension/src/capability.rs index b48a38744..3f80cdce9 100644 --- a/crates/ide-extension/src/capability.rs +++ b/crates/ide-extension/src/capability.rs @@ -230,11 +230,13 @@ mod tests { "net:crates.io", "net:*.github.com", "process:cargo", - "process:/usr/bin/git", "fs.read:/etc", ] { assert_eq!(s.parse::().unwrap().to_string(), s); } + // An absolute program path is the platform's. + let git = if cfg!(windows) { r"process:C:\Program Files\Git\bin\git.exe" } else { "process:/usr/bin/git" }; + assert_eq!(git.parse::().unwrap().to_string(), git); assert_eq!("project-read".parse::().unwrap(), Capability::ProjectRead); assert!("net:".parse::().is_err()); assert!("net:a b".parse::().is_err()); diff --git a/crates/ide-host/src/plugins/market.rs b/crates/ide-host/src/plugins/market.rs index b3112f4fb..a9e045c14 100644 --- a/crates/ide-host/src/plugins/market.rs +++ b/crates/ide-host/src/plugins/market.rs @@ -56,13 +56,20 @@ pub fn read_url(url: &str, max: usize) -> Result, String> { Ok(bytes) } -/// `href` relative to the index at `base`. +/// `href` relative to the index at `base`: a URL, or a local path (with the +/// platform's separators, e.g. `C:\\market\\index.json`). pub fn resolve(base: &str, href: &str) -> String { - if href.contains("://") || href.starts_with('/') { + if href.contains("://") || href.starts_with('/') || Path::new(href).is_absolute() { return href.to_owned(); } - match base.rfind('/') { - Some(i) => format!("{}/{href}", &base[..i]), + if base.contains("://") { + return match base.rfind('/') { + Some(i) => format!("{}/{href}", &base[..i]), + None => href.to_owned(), + }; + } + match Path::new(base).parent() { + Some(dir) => dir.join(href).to_string_lossy().into_owned(), None => href.to_owned(), } } @@ -296,7 +303,12 @@ mod tests { #[test] fn downloads_resolve_against_the_index() { assert_eq!(resolve("https://x.dev/market/index.json", "p/a.oiplugin"), "https://x.dev/market/p/a.oiplugin"); - assert_eq!(resolve("/srv/market/index.json", "a.oiplugin"), "/srv/market/a.oiplugin"); + assert_eq!( + Path::new(&resolve("/srv/market/index.json", "a.oiplugin")), + Path::new("/srv/market").join("a.oiplugin") + ); + #[cfg(windows)] + assert_eq!(resolve(r"C:\market\index.json", "a.oiplugin"), r"C:\market\a.oiplugin"); assert_eq!(resolve("https://x.dev/index.json", "https://cdn.dev/a"), "https://cdn.dev/a"); } } diff --git a/crates/ide-host/tests/plugins.rs b/crates/ide-host/tests/plugins.rs index 61201070b..4be30a988 100644 --- a/crates/ide-host/tests/plugins.rs +++ b/crates/ide-host/tests/plugins.rs @@ -175,7 +175,9 @@ fn hello_02(text: &str, required: &str) -> String { /// `hello` with a 0.1 manifest (`openidea-api`, `permissions`, no activation). fn hello_01(text: &str) -> String { - text.replace("engines.openidea = \"^1.0\"", "openidea-api = \"0.1\"") + // A Windows checkout may have CRLF line ends. + text.replace("\r\n", "\n") + .replace("engines.openidea = \"^1.0\"", "openidea-api = \"0.1\"") .replace("activation = [\"on-command:*\", \"on-view:greetings\"]\n", "") .replace( "[capabilities]\nrequired = [\"editor.read\", \"editor.write\"]", @@ -1164,19 +1166,19 @@ fn precompiled_components_make_cold_activation_cheap() { std::fs::write(cache.join("0000-stale.cwasm"), b"junk").unwrap(); again.warm_component_cache(); assert_eq!(cached(), 1); - // A corrupt entry is discarded and recompiled. Replaced, not overwritten: - // the running component maps the file it was loaded from. + // A corrupt entry is discarded and recompiled. The host that loaded it goes + // first: its component maps the file (which Windows won't let anyone replace). + drop(again); for e in std::fs::read_dir(&cache).unwrap().flatten() { - let junk = e.path().with_extension("junk"); - std::fs::write(&junk, b"junk").unwrap(); - std::fs::rename(&junk, e.path()).unwrap(); + std::fs::write(e.path(), b"junk").unwrap(); } - again.reload("hello").unwrap(); + let third = PluginHost::new(f.plugins_dir.clone(), Arc::new(|_: Option<&str>, _| {}), Limits::default()).unwrap(); + third.load_all(); assert!( - again.run_command("hello", "word-count", None, Invocation::default()).is_err(), + third.run_command("hello", "word-count", None, Invocation::default()).is_err(), "no active file, but it runs" ); - assert_eq!(again.info_of("hello").unwrap().status, PluginStatus::Active); + assert_eq!(third.info_of("hello").unwrap().status, PluginStatus::Active); } #[test] @@ -1342,9 +1344,12 @@ fn thousands_of_icons_install_and_reload_within_budget() { let reload = started.elapsed(); assert_eq!(again.assets("acme.many", &icon_theme_svg_prefix("many")).unwrap().len(), ICONS); eprintln!("{ICONS} icons: install {install:?}, load at start-up {reload:?} (from the contribution index)"); - // Generous for debug builds; release is several times faster. - assert!(install < Duration::from_secs(5), "install took {install:?}"); - assert!(reload < Duration::from_secs(3), "start-up load took {reload:?}"); + // Guards against work that grows faster than the icon count, not a + // benchmark: a debug build takes ~1.6 s / ~0.35 s on a laptop; shared CI + // runners running the suite in parallel take several times that (Windows + // ~11 s to install). + assert!(install < Duration::from_secs(30), "install took {install:?}"); + assert!(reload < Duration::from_secs(10), "start-up load took {reload:?}"); // Start-up reads the validated declaration from the index, not the SVGs: // a tampered icon file is not even looked at... @@ -1814,6 +1819,9 @@ fn official_mybatis_navigates_between_interface_and_xml() { text: text.into(), }; let jdoc = doc(&java_path, "java", java); + // The first call indexes the XML files; on a slow machine that can run past + // the edit-path budget (the answer is dropped, the index kept). + let _ = f.host.provide_line_markers(&jdoc); let markers = f.host.provide_line_markers(&jdoc); assert_eq!(markers.len(), 1, "{markers:?}"); let target = markers[0].1.target.clone().unwrap(); @@ -1831,6 +1839,7 @@ fn official_mybatis_navigates_between_interface_and_xml() { ); // Warm (index cached): well inside the edit-path budget. + let slow = f.host.info_of("openidea.mybatis").unwrap().stats.slow_calls; let mut times = Vec::new(); for _ in 0..20 { let started = Instant::now(); @@ -1840,7 +1849,7 @@ fn official_mybatis_navigates_between_interface_and_xml() { times.sort(); eprintln!("mybatis line markers p95: {:?}", times[18]); assert!(times[18] < Duration::from_millis(50), "{times:?}"); - assert_eq!(f.host.info_of("openidea.mybatis").unwrap().stats.slow_calls, 0); + assert_eq!(f.host.info_of("openidea.mybatis").unwrap().stats.slow_calls, slow); } /// CSV Editor: a custom editor renders the document as a table (the diff --git a/crates/ide-http/src/client.rs b/crates/ide-http/src/client.rs index b7b9dfa59..05963a4c5 100644 --- a/crates/ide-http/src/client.rs +++ b/crates/ide-http/src/client.rs @@ -35,8 +35,16 @@ pub fn build(proxy: &ProxySettings) -> IdeResult { .with_platform_verifier() .map_err(|e| fail(e.to_string()))? .with_no_client_auth(); + // `localhost` is tried on IPv4 first: local servers (Ollama, dev servers) + // usually listen there, and on Windows a refused `::1` costs ~2 s before + // the system resolver's order falls back. Ports come from the URL. + let loopback = [ + std::net::SocketAddr::from((std::net::Ipv4Addr::LOCALHOST, 0)), + std::net::SocketAddr::from((std::net::Ipv6Addr::LOCALHOST, 0)), + ]; let builder = reqwest::Client::builder() .tls_backend_preconfigured(tls) + .resolve_to_addrs("localhost", &loopback) .connect_timeout(Duration::from_secs(30)) .user_agent(concat!("OpenIDEA-HttpClient/", env!("CARGO_PKG_VERSION"))); with_proxy(builder, proxy).build().map_err(|e| fail(e.to_string())) @@ -113,7 +121,11 @@ mod tests { /// A one-shot HTTP server on loopback: answers `200` and hands back the request head. fn server() -> (u16, std::thread::JoinHandle) { - let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + server_on("127.0.0.1:0") + } + + fn server_on(addr: &str) -> (u16, std::thread::JoinHandle) { + let listener = TcpListener::bind(addr).unwrap(); let port = listener.local_addr().unwrap().port(); let handle = std::thread::spawn(move || { let (stream, _) = listener.accept().unwrap(); @@ -141,6 +153,21 @@ mod tests { check_connection(proxy, url, &CancellationToken::new()).await } + /// `localhost` reaches a server on either loopback address. + #[tokio::test] + async fn localhost_reaches_ipv4_and_ipv6_servers() { + let direct = ProxySettings::default(); + let (port, v4) = server(); + assert_eq!(get(&direct, &format!("http://localhost:{port}/")).await.unwrap(), "200 OK"); + v4.join().unwrap(); + // Hosts without IPv6 loopback skip this half. + if TcpListener::bind("[::1]:0").is_ok() { + let (port, v6) = server_on("[::1]:0"); + assert_eq!(get(&direct, &format!("http://localhost:{port}/")).await.unwrap(), "200 OK"); + v6.join().unwrap(); + } + } + #[tokio::test] async fn builds_for_every_mode() { for mode in [ProxyMode::None, ProxyMode::Auto, ProxyMode::Manual] {