From 0276cf8028107737a8d7e08c11e438287d480d74 Mon Sep 17 00:00:00 2001 From: Teakowa <27560638+Teakowa@users.noreply.github.com> Date: Tue, 27 Oct 2026 18:14:26 +0800 Subject: [PATCH 1/2] refactor: route every LPP provider client through one connect path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The LPP connect sequence — resolve the provider, spawn it, initialize at the preferred protocol version, and on protocol-version-mismatch shut down, respawn, and retry at the highest mutually supported version — was written twice and had drifted: - provider lookup: the driver session resolved through `resolve()` while the language service fell back to `installed()`; - the driver's retry honored a per-target protocol floor (LPP §19.2), the language service's had none; - the same provider refusal produced `provider-refusal-` in the driver and the raw refusal code in the LSP; - fix context, rename, and convert connected with plain `initialize` and no negotiation at all. `wright_driver::connect::connect_provider` is now the single connect path: it owns `opy` resolution (every client resolves; the owner decision removes the LSP `installed()` fallback), the floored highest-mutual-version retry, and hands every caller the same typed failure so the shared `provider-refusal-` classification applies on every surface. The driver session load, `run_provider_flow` (fix context and rename), `opy_lookup`, `convert_opy`, and `LanguageService`'s OPY provider all connect through it. The LSP's parallel negotiation copy is deleted. Tests: the existing driver-side version-mismatch retry test keeps covering the CLI path; a new language-service test spawns a provider that rejects the preferred version, verifies the retry at the highest mutual version, and asserts an `lpp/check` refusal surfaces as `provider-refusal-` — the driver contract — instead of the raw refusal code. The LSP and language-service boundary tests now pin provider resolution offline (`WRIGHT_OPY_PROVIDER_BASE_URL` pointing at an unreachable host; a nonexistent explicit executable) so the resolve-only contract stays deterministic without network access. Fixes #1004 --- crates/wright-driver/src/connect.rs | 194 +++++++++++++++++ crates/wright-driver/src/lib.rs | 4 + crates/wright-driver/src/lookup.rs | 31 +-- crates/wright-driver/src/session.rs | 213 ++++++++----------- crates/wright-driver/src/session/protocol.rs | 26 --- crates/wright-driver/src/source_provider.rs | 2 +- crates/wright-language/Cargo.toml | 1 + crates/wright-language/src/service.rs | 132 +++++------- crates/wright-language/tests/service.rs | 116 +++++++++- crates/wright-lsp/tests/lsp.rs | 8 +- docs/language-services.md | 8 +- 11 files changed, 470 insertions(+), 265 deletions(-) create mode 100644 crates/wright-driver/src/connect.rs delete mode 100644 crates/wright-driver/src/session/protocol.rs diff --git a/crates/wright-driver/src/connect.rs b/crates/wright-driver/src/connect.rs new file mode 100644 index 00000000..2348a719 --- /dev/null +++ b/crates/wright-driver/src/connect.rs @@ -0,0 +1,194 @@ +//! The one LPP connect path every Wright provider client shares (#1004): +//! resolve the provider for the language id, spawn it, initialize at the +//! preferred protocol version, and — on `protocol-version-mismatch` — +//! shut down, respawn, and retry once at the highest mutually supported +//! version above the caller's floor (LPP §19.2). +//! +//! Provider lookup is the session's registry plus the `opy` id's +//! configured resolver — there is no `installed()` fallback (the 2026-10-27 +//! owner decision on #1004). Failures surface as `ProviderError`; callers +//! map them through the shared `provider-refusal-` classification +//! (`crate::source_provider::provider_failure_code`). + +use crate::config::SessionConfig; +use crate::input::InputTarget; +use crate::opy_provider; +use wright_lpp::LanguageProvider; + +/// The lowest LPP protocol version a connect may negotiate. `Entry` +/// requests need 1.1 project loading; `Directory` targets need the 1.3 +/// `sourceIdentity` contract they load under (#638). Document-supplied +/// requests take `None` — the base 1.0 contract. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConnectFloor { + Entry, + Directory, +} + +impl From for ConnectFloor { + fn from(target: InputTarget) -> ConnectFloor { + match target { + InputTarget::Directory => ConnectFloor::Directory, + _ => ConnectFloor::Entry, + } + } +} + +/// The result of one [`connect_provider`] call: the spawned, initialized +/// provider session and the negotiated `lpp/initialize` answer. +pub struct ConnectedProvider { + pub provider: Box, + pub initialize: wright_lpp::InitializeResult, +} + +/// Resolve, spawn, and initialize an LPP provider session for +/// `language_id` — the single connect path every Wright LPP client uses +/// (#1004). +/// +/// `preferred_version` is the client's first offer — the highest protocol +/// version its requests need. On `protocol-version-mismatch` the provider +/// is shut down, respawned, and re-initialized once at the highest version +/// both sides support that still clears `floor`; when no such version +/// exists the typed `ProtocolVersionMismatch` error answers. +/// +/// A `None` floor keeps the base `1.0` contract (the language service's +/// document-supplied requests). +pub fn connect_provider( + config: &SessionConfig, + language_id: &str, + client: &wright_lpp::ClientInfo, + preferred_version: &str, + floor: Option, +) -> Result { + let providers = resolved_registry(config, language_id)?; + let spawn = || providers.spawn(language_id); + let mut provider = spawn()?; + let mut initialize = provider.initialize_version(preferred_version, Some(client)); + if initialize + .as_ref() + .is_err_and(|error| error.code() == "protocol-version-mismatch") + { + // LPP §19.2: retry at the highest mutually supported version the + // provider advertised, honoring the caller's floor. + let supported = initialize + .as_ref() + .err() + .map(|error| error.supported_protocol_versions()) + .unwrap_or_default(); + let _ = provider.shutdown(); + provider = spawn()?; + initialize = match mutual_protocol_version(&supported, floor) { + Some(version) => provider.initialize_version(version, Some(client)), + None => Err(wright_lpp::ProviderError::ProtocolVersionMismatch { + supported, + message: format!("no mutually supported LPP protocol version serves {language_id}"), + }), + }; + } + initialize.map(|initialize| ConnectedProvider { + provider: Box::new(provider), + initialize, + }) +} + +/// The registry to spawn from: the session's configured providers, with +/// the `opy` id resolved through its configured resolver when no explicit +/// registration wins (owner decision: every client resolves; no +/// `installed()` fallback). Any other unconfigured id is `NotConfigured`. +pub(crate) fn resolved_registry( + config: &SessionConfig, + language_id: &str, +) -> Result { + if language_id != opy_provider::OPY_LANGUAGE_ID || config.providers.contains(language_id) { + return Ok(config.providers.clone()); + } + let resolved = config.opy_provider.resolve()?; + let mut providers = config.providers.clone(); + providers + .register(wright_lpp::ProviderConfig::new( + opy_provider::OPY_LANGUAGE_ID, + resolved.executable, + Vec::new(), + )) + .expect("a registry without the 'opy' id accepts its registration"); + Ok(providers) +} + +/// The highest client-supported protocol version the provider also +/// supports, at or above `floor`'s minimum (LPP §19.2). +fn mutual_protocol_version( + supported: &[String], + floor: Option, +) -> Option<&'static str> { + let minimum = match floor { + Some(ConnectFloor::Directory) => wright_lpp::LPP_SOURCE_IDENTITY_PROTOCOL_VERSION, + Some(ConnectFloor::Entry) => wright_lpp::LPP_PROJECT_LOADING_PROTOCOL_VERSION, + None => wright_lpp::LPP_PROTOCOL_VERSION, + }; + let minimum = protocol_version_tuple(minimum)?; + wright_lpp::LPP_SUPPORTED_PROTOCOL_VERSIONS + .iter() + .copied() + .filter_map(|version| protocol_version_tuple(version).map(|tuple| (tuple, version))) + .filter(|(tuple, _)| *tuple >= minimum) + .filter(|(_, version)| supported.iter().any(|s| s == version)) + .max_by_key(|(tuple, _)| *tuple) + .map(|(_, version)| version) +} + +fn protocol_version_tuple(version: &str) -> Option<(u32, u32)> { + let mut parts = version.split('.'); + let major = parts.next()?.parse().ok()?; + let minor = parts.next()?.parse().ok()?; + Some((major, minor)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn mutual_protocol_version_honors_the_target_floor() { + let supported = + |versions: &[&str]| versions.iter().map(|v| v.to_string()).collect::>(); + + assert_eq!( + mutual_protocol_version( + &supported(&["1.0", "1.1", "1.3"]), + Some(ConnectFloor::Entry) + ), + Some("1.3") + ); + assert_eq!( + mutual_protocol_version(&supported(&["1.1", "1.4"]), Some(ConnectFloor::Entry)), + Some("1.4") + ); + + assert_eq!( + mutual_protocol_version(&supported(&["1.0"]), Some(ConnectFloor::Entry)), + None + ); + assert_eq!( + mutual_protocol_version(&supported(&["1.0", "1.1"]), Some(ConnectFloor::Entry)), + Some("1.1") + ); + + assert_eq!( + mutual_protocol_version(&supported(&["1.1", "1.2"]), Some(ConnectFloor::Directory)), + None + ); + assert_eq!( + mutual_protocol_version(&supported(&["1.2", "1.3"]), Some(ConnectFloor::Directory)), + Some("1.3") + ); + + assert_eq!( + mutual_protocol_version(&supported(&["1.6"]), Some(ConnectFloor::Entry)), + None + ); + assert_eq!( + mutual_protocol_version(&supported(&["1.0"]), None), + Some("1.0") + ); + } +} diff --git a/crates/wright-driver/src/lib.rs b/crates/wright-driver/src/lib.rs index be1a77cf..e10ffc5a 100644 --- a/crates/wright-driver/src/lib.rs +++ b/crates/wright-driver/src/lib.rs @@ -2,6 +2,8 @@ pub mod cache; pub mod config; +#[cfg(not(target_arch = "wasm32"))] +pub mod connect; pub mod diag; pub mod edit; pub mod essential; @@ -32,6 +34,8 @@ pub use config::{ InputSpec, LintConfig, LintPackageConfig, LintPackagePin, OutputFormat, RunOptions, SessionConfig, SourceKind, }; +#[cfg(not(target_arch = "wasm32"))] +pub use connect::{ConnectFloor, ConnectedProvider}; pub use diag::{Diagnostic, Origin, Position, Severity, SourceSpan, Stage, Status}; pub use input::{InputTarget, ResolvedInput, sha256_hex}; #[cfg(not(target_arch = "wasm32"))] diff --git a/crates/wright-driver/src/lookup.rs b/crates/wright-driver/src/lookup.rs index 92b188c6..1bafafa4 100644 --- a/crates/wright-driver/src/lookup.rs +++ b/crates/wright-driver/src/lookup.rs @@ -518,28 +518,29 @@ fn opy_lookup( locale: Option<&str>, limit: usize, ) -> ToolResponse { - let mut provider = match session.language_provider(opy_provider::OPY_LANGUAGE_ID) { - Ok(provider) => provider, - Err(error) => { - return unavailable("opy", OWNER_OPY, LOOKUP_CAPABILITY, error.to_string()); - } - }; let client = wright_lpp::ClientInfo { name: SERVICE_NAME.to_string(), version: SERVICE_VERSION.to_string(), }; - let negotiated = match provider.initialize_lookup(Some(&client)) { - Ok(result) => result.capabilities, + let mut provider = match session.connect_provider( + opy_provider::OPY_LANGUAGE_ID, + &client, + wright_lpp::LPP_LOOKUP_PROTOCOL_VERSION, + Some(crate::ConnectFloor::Entry), + ) { + Ok(connected) => connected.provider, + // A connect failure — an unresolvable provider, a refused + // handshake, a version mismatch — is a capability gap on this + // surface, the same contract the spawn/initialize failures + // reported before the shared connect path (#1004). Err(error) => { - let _ = provider.shutdown(); - return match error.code() { - "protocol-version-mismatch" | "capability-unavailable" => { - unavailable("opy", OWNER_OPY, LOOKUP_CAPABILITY, error.to_string()) - } - _ => carried_refusal(error.code(), error.to_string()), - }; + return unavailable("opy", OWNER_OPY, LOOKUP_CAPABILITY, error.to_string()); } }; + let negotiated = provider + .capabilities() + .expect("a connected provider negotiated") + .clone(); if !negotiated.supports(Capability::Lookup) { let _ = provider.shutdown(); return unavailable( diff --git a/crates/wright-driver/src/session.rs b/crates/wright-driver/src/session.rs index 1d58ca9b..c5568dac 100644 --- a/crates/wright-driver/src/session.rs +++ b/crates/wright-driver/src/session.rs @@ -1,15 +1,11 @@ mod diagnostics; mod edit; -#[cfg(not(target_arch = "wasm32"))] -mod protocol; mod registry; mod semantic; #[cfg(not(target_arch = "wasm32"))] use diagnostics::provider_error_diagnostic; use diagnostics::workshop_diag_for_provider_artifact; -#[cfg(not(target_arch = "wasm32"))] -use protocol::mutual_protocol_version; pub(crate) use semantic::*; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -379,59 +375,32 @@ impl CompilerSession { // WebAssembly, but no process transport exists to create one. #[cfg(not(target_arch = "wasm32"))] if self.source_provider.is_none() { - let spawn = |session: &Self| { - session - .language_provider(opy_provider::OPY_LANGUAGE_ID) - .map_err(|error| { - crate::source_provider::provider_error( - error, - "the 'opy' source provider could not be started", - ) - .diagnostic() - }) - }; - let mut provider = spawn(self)?; - let client_info = lpp_client_info(); - let mut initialize = provider.initialize_artifact_negotiation(Some(&client_info)); - if initialize - .as_ref() - .is_err_and(|error| error.code() == "protocol-version-mismatch") - { - // LPP §19.2: retry at the highest mutually supported - // version the provider advertised, honoring the target's - // floor — entry requests need 1.1 project loading, and a - // directory target cannot drop below the 1.3 - // source-identity contract it loads under (#638). - let supported = initialize - .as_ref() - .err() - .map(|error| error.supported_protocol_versions()) - .unwrap_or_default(); - let _ = provider.shutdown(); - provider = spawn(self)?; - initialize = match mutual_protocol_version(&supported, resolved.target) { - Some(version) => provider.initialize_version(version, Some(&client_info)), - None => Err(wright_lpp::ProviderError::ProtocolVersionMismatch { - supported, - message: format!( - "no mutually supported LPP protocol version serves a {:?} target", - resolved.target - ), - }), - }; - } - let init = initialize.map_err(|error| { - crate::source_provider::provider_error( - error, - "the 'opy' source provider could not be initialized", + // The shared connect path (#1004): entry requests prefer the + // 1.4 artifact-negotiation session, and the target's kind sets + // the retry floor — entry requests need 1.1 project loading, + // and a directory target cannot drop below the 1.3 + // source-identity contract it loads under (#638). + let connected = self + .connect_provider( + opy_provider::OPY_LANGUAGE_ID, + &lpp_client_info(), + wright_lpp::LPP_ARTIFACT_NEGOTIATION_PROTOCOL_VERSION, + Some(resolved.target.into()), ) - .diagnostic() - })?; + .map_err(|error| { + crate::source_provider::provider_error( + error, + "the 'opy' source provider could not be initialized", + ) + .diagnostic() + })?; // A directory target's canonical identity is the provider's // `sourceIdentity` (LPP 1.3): the spec requires the client to // refuse a session that negotiated without the capability // (#638). - if resolved.target == InputTarget::Directory && !init.capabilities.source_identity { + if resolved.target == InputTarget::Directory + && !connected.initialize.capabilities.source_identity + { return Err(crate::source_provider::provider_error( wright_lpp::ProviderError::lpp( wright_lpp::LppErrorKind::CapabilityUnavailable, @@ -446,7 +415,7 @@ impl CompilerSession { .diagnostic()); } self.source_provider = Some(Box::new(crate::source_provider::LppSourceProvider::new( - provider, + connected.provider, self.config.locale.clone(), ))); } @@ -639,12 +608,16 @@ impl CompilerSession { /// /// Providers are discovered by opaque language id through the session's /// provider registry; no source-language branch lives here. When no - /// provider is configured for the id, or when a required capability was - /// not negotiated, the failure is an explicit structured - /// `wright_lpp::ProviderError` — there is no silent fallback to - /// in-process compiler semantics. A session whose configuration changed - /// after construction refuses through the LPP refusal channel with - /// `refusalCode` `session-config-changed` (#511). + /// provider is configured for the id the failure is an explicit + /// structured `wright_lpp::ProviderError` — there is no silent fallback + /// to in-process compiler semantics. A session whose configuration + /// changed after construction refuses through the LPP refusal channel + /// with `refusalCode` `session-config-changed` (#511). + /// + /// Callers that intend to initialize the session should prefer + /// [`Self::connect_provider`], the shared connect path that also + /// negotiates the protocol version (#1004); this spawn-only seam + /// remains for hosts that initialize the returned provider themselves. #[cfg(not(target_arch = "wasm32"))] pub fn language_provider( &self, @@ -657,28 +630,48 @@ impl CompilerSession { diagnostic.message, )); } - if id == opy_provider::OPY_LANGUAGE_ID && !self.config.providers.contains(id) { - let res = self.config.opy_provider.resolve()?; - let mut p = self.config.providers.clone(); - p.register(wright_lpp::ProviderConfig::new( - opy_provider::OPY_LANGUAGE_ID, - res.executable, - Vec::new(), - )) - .expect("registered"); - return p - .spawn(id) - .map(|b| Box::new(b) as Box); - } - self.config - .providers + crate::connect::resolved_registry(&self.config, id)? .spawn(id) .map(|b| Box::new(b) as Box) } + /// The shared LPP connect path (#1004): resolve `language_id`, spawn + /// the provider, initialize at `preferred_version`, and retry once at + /// the highest mutually supported version above `floor` on + /// `protocol-version-mismatch` (LPP §19.2). The session-config refusal + /// channel (`session-config-changed`, #511) precedes any spawn. + /// + /// Every Wright provider client — the source-provider session load, + /// provider mutation flows, lookup, and the language service — + /// connects through this function, so lookup, negotiation, and + /// failure classification cannot drift apart. + #[cfg(not(target_arch = "wasm32"))] + pub fn connect_provider( + &self, + language_id: &str, + client: &wright_lpp::ClientInfo, + preferred_version: &str, + floor: Option, + ) -> Result { + if let Err(diagnostic) = self.verify_fixed_config() { + return Err(wright_lpp::ProviderError::lpp( + wright_lpp::LppErrorKind::Refusal, + serde_json::json!({ "refusalCode": diagnostic.code }), + diagnostic.message, + )); + } + crate::connect::connect_provider( + &self.config, + language_id, + client, + preferred_version, + floor, + ) + } + /// Run a provider-driven mutation flow (#139) over a fresh provider - /// session: spawn by opaque language id, initialize with `client`, run - /// the flow, and terminate gracefully. + /// session: connect by opaque language id through the shared connect + /// path (#1004), run the flow, and terminate gracefully. /// /// Any failure before the flow — an unconfigured language id, a spawn /// failure, a failed handshake — is the same structured @@ -695,13 +688,15 @@ impl CompilerSession { &mut dyn wright_lpp::LanguageProvider, ) -> crate::provider_edit::ProviderMutation, ) -> crate::provider_edit::ProviderMutation { - let mut provider = match self.language_provider(language_id) { - Ok(provider) => provider, + let mut provider = match self.connect_provider( + language_id, + client, + wright_lpp::LPP_PROTOCOL_VERSION, + None, + ) { + Ok(connected) => connected.provider, Err(error) => return crate::provider_edit::provider_failure(&error), }; - if let Err(error) = provider.initialize(Some(client)) { - return crate::provider_edit::provider_failure(&error); - } let mutation = flow(provider.as_mut()); let _ = provider.shutdown(); mutation @@ -1008,14 +1003,17 @@ impl CompilerSession { )); })?; let mut provider = self - .language_provider(opy_provider::OPY_LANGUAGE_ID) + .connect_provider( + opy_provider::OPY_LANGUAGE_ID, + &lpp_client_info(), + wright_lpp::LPP_PROTOCOL_VERSION, + None, + ) .map_err(|e| { - self.diagnostics.push(provider_error_diagnostic(e, "start")); - })?; - provider.initialize(Some(&lpp_client_info())).map_err(|e| { - self.diagnostics - .push(provider_error_diagnostic(e, "initialize")); - })?; + self.diagnostics + .push(provider_error_diagnostic(e, "initialize")); + })? + .provider; let result = provider .reconstruct(&wright_lpp::WorkshopArtifact { format: "workshop-rs/text-v1".to_string(), @@ -1320,45 +1318,6 @@ fn issue_span(span: Option, path: &Path) -> SourceSpa mod tests { use super::*; - #[test] - #[cfg(not(target_arch = "wasm32"))] - fn mutual_protocol_version_honors_the_target_floor() { - let supported = - |versions: &[&str]| versions.iter().map(|v| v.to_string()).collect::>(); - - assert_eq!( - mutual_protocol_version(&supported(&["1.0", "1.1", "1.3"]), InputTarget::File), - Some("1.3") - ); - assert_eq!( - mutual_protocol_version(&supported(&["1.1", "1.4"]), InputTarget::File), - Some("1.4") - ); - - assert_eq!( - mutual_protocol_version(&supported(&["1.0"]), InputTarget::File), - None - ); - assert_eq!( - mutual_protocol_version(&supported(&["1.0", "1.1"]), InputTarget::File), - Some("1.1") - ); - - assert_eq!( - mutual_protocol_version(&supported(&["1.1", "1.2"]), InputTarget::Directory), - None - ); - assert_eq!( - mutual_protocol_version(&supported(&["1.2", "1.3"]), InputTarget::Directory), - Some("1.3") - ); - - assert_eq!( - mutual_protocol_version(&supported(&["1.6"]), InputTarget::File), - None - ); - } - #[test] fn residual_classifications_map_fail_closed() { use workshop_rs::rules::ResidualClassification as C; diff --git a/crates/wright-driver/src/session/protocol.rs b/crates/wright-driver/src/session/protocol.rs deleted file mode 100644 index 5e6ae31d..00000000 --- a/crates/wright-driver/src/session/protocol.rs +++ /dev/null @@ -1,26 +0,0 @@ -use crate::input::InputTarget; - -pub(super) fn mutual_protocol_version( - supported: &[String], - target: InputTarget, -) -> Option<&'static str> { - let floor = match target { - InputTarget::Directory => wright_lpp::LPP_SOURCE_IDENTITY_PROTOCOL_VERSION, - _ => wright_lpp::LPP_PROJECT_LOADING_PROTOCOL_VERSION, - }; - let floor = protocol_version_tuple(floor)?; - wright_lpp::LPP_SUPPORTED_PROTOCOL_VERSIONS - .iter() - .copied() - .filter_map(|version| protocol_version_tuple(version).map(|tuple| (tuple, version))) - .filter(|(tuple, _)| *tuple >= floor) - .filter(|(_, version)| supported.iter().any(|s| s == version)) - .max_by_key(|(tuple, _)| *tuple) - .map(|(_, version)| version) -} -fn protocol_version_tuple(version: &str) -> Option<(u32, u32)> { - let mut parts = version.split('.'); - let major = parts.next()?.parse().ok()?; - let minor = parts.next()?.parse().ok()?; - Some((major, minor)) -} diff --git a/crates/wright-driver/src/source_provider.rs b/crates/wright-driver/src/source_provider.rs index c1bc0a8e..ef501c3d 100644 --- a/crates/wright-driver/src/source_provider.rs +++ b/crates/wright-driver/src/source_provider.rs @@ -326,7 +326,7 @@ fn request_context(operation: &str, target: &SourceTarget) -> String { } #[cfg(not(target_arch = "wasm32"))] -pub(crate) fn provider_failure_code(error: &wright_lpp::ProviderError) -> String { +pub fn provider_failure_code(error: &wright_lpp::ProviderError) -> String { match error.refusal_code() { Some(code) if code == "session-config-changed" => code.to_string(), Some(code) => format!("{PROVIDER_REFUSAL_CODE_PREFIX}{code}"), diff --git a/crates/wright-language/Cargo.toml b/crates/wright-language/Cargo.toml index 09d3beef..bc13ee32 100644 --- a/crates/wright-language/Cargo.toml +++ b/crates/wright-language/Cargo.toml @@ -12,6 +12,7 @@ workspace = true [dependencies] serde = { workspace = true, features = ["derive"] } +serde_json.workspace = true url = "2" wright-analyzer.workspace = true wright-driver.workspace = true diff --git a/crates/wright-language/src/service.rs b/crates/wright-language/src/service.rs index 1ee4d4a7..2f751b53 100644 --- a/crates/wright-language/src/service.rs +++ b/crates/wright-language/src/service.rs @@ -1,10 +1,9 @@ use std::collections::BTreeSet; use std::path::PathBuf; -use std::time::Duration; use serde::Serialize; use wright_analyzer::canonical::{PositionResolution, ReferenceKind, SemanticIndex, Symbol}; -use wright_driver::opy_provider::{OPY_LANGUAGE_ID, ResolvedOpyProvider}; +use wright_driver::opy_provider::OPY_LANGUAGE_ID; use wright_driver::{CompilerSession, InputSpec, SessionConfig, SourceKind}; use wright_lpp::{Capability, LanguageProvider, ProviderError}; @@ -211,7 +210,12 @@ impl LanguageService { }; let provider = match self.opy_provider() { Ok(provider) => provider, - Err(error) => return boundary("source-provider-unavailable", error.to_string()), + Err(error) => { + return boundary( + &wright_driver::source_provider::provider_failure_code(&error), + error.to_string(), + ); + } }; if !provider_supports(provider, Capability::Check) { return boundary( @@ -243,7 +247,13 @@ impl LanguageService { }) }) .collect(), - Err(error) => boundary(provider_failure_code(&error), error.to_string()), + // The shared provider-failure classification (#1004): a + // refusal reports `provider-refusal-` — the + // driver contract — never the raw refusal code. + Err(error) => boundary( + &wright_driver::source_provider::provider_failure_code(&error), + error.to_string(), + ), } } @@ -416,15 +426,10 @@ impl LanguageService { sources, }; - if self.session.is_none() { - self.session = match CompilerSession::new(self.config.clone()) { - Ok(session) => Some(session), - Err(diagnostic) => { - return RenameOutcome::Refused { - code: diagnostic.code, - message: diagnostic.message, - }; - } + if let Err(error) = self.session() { + return RenameOutcome::Refused { + code: wright_driver::source_provider::provider_failure_code(&error), + message: error.to_string(), }; } let mutation = self @@ -509,7 +514,7 @@ impl LanguageService { fn opy_provider(&mut self) -> Result<&mut dyn LanguageProvider, ProviderError> { if matches!(self.opy, OpyProviderState::Pending) { - self.opy = match connect_opy_provider(&self.config) { + self.opy = match self.connect_opy_provider() { Ok(provider) => OpyProviderState::Ready(provider), Err(error) => OpyProviderState::Unavailable(error), }; @@ -521,6 +526,43 @@ impl LanguageService { } } + /// The shared session, built lazily on the first provider request so a + /// service that only serves Workshop documents never constructs one. + fn session(&mut self) -> Result<&CompilerSession, ProviderError> { + if self.session.is_none() { + match CompilerSession::new(self.config.clone()) { + Ok(session) => self.session = Some(session), + Err(diagnostic) => { + return Err(ProviderError::lpp( + wright_lpp::LppErrorKind::Refusal, + serde_json::json!({ "refusalCode": diagnostic.code }), + diagnostic.message, + )); + } + } + } + Ok(self.session.as_ref().expect("session built above")) + } + + /// The OPY provider session, connected through the driver's shared + /// connect path (#1004): `resolve()` lookup (no `installed()` + /// fallback), preferred-version initialize, and the LPP §19.2 retry. + fn connect_opy_provider(&mut self) -> Result, ProviderError> { + let client = wright_lpp::ClientInfo { + name: wright_lpp::LPP_CLIENT_NAME.to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + }; + Ok(self + .session()? + .connect_provider( + OPY_LANGUAGE_ID, + &client, + wright_lpp::LPP_PROTOCOL_VERSION, + None, + )? + .provider) + } + fn opy_document(&self, uri: &str) -> Option { let document = self.store.document(uri)?; if source_language_id(uri).as_deref() != Some(OPY_LANGUAGE_ID) { @@ -561,64 +603,6 @@ impl LanguageService { } } -const PROVIDER_REQUEST_TIMEOUT: Duration = Duration::from_secs(30); - -fn connect_opy_provider( - config: &SessionConfig, -) -> Result, ProviderError> { - let resolved = if config.opy_provider.executable.is_some() { - Some(config.opy_provider.resolve()?) - } else { - config.opy_provider.installed()? - }; - let Some(resolved) = resolved else { - return Err(ProviderError::NotConfigured { - language_id: OPY_LANGUAGE_ID.to_string(), - }); - }; - let client = wright_lpp::ClientInfo { - name: wright_lpp::LPP_CLIENT_NAME.to_string(), - version: env!("CARGO_PKG_VERSION").to_string(), - }; - let spawn = |resolved: &ResolvedOpyProvider| { - wright_lpp::StdioLanguageProvider::spawn( - &resolved.executable, - &[], - PROVIDER_REQUEST_TIMEOUT, - ) - }; - let mut provider = spawn(&resolved)?; - let mut initialize = provider.initialize(Some(&client)); - if initialize - .as_ref() - .is_err_and(|error| error.code() == "protocol-version-mismatch") - { - let supported = initialize - .as_ref() - .err() - .map(|error| error.supported_protocol_versions()) - .unwrap_or_default(); - let _ = provider.shutdown(); - provider = spawn(&resolved)?; - initialize = match mutual_protocol_version(&supported) { - Some(version) => provider.initialize_version(version, Some(&client)), - None => Err(ProviderError::ProtocolVersionMismatch { - supported, - message: "no mutually supported LPP protocol version".to_string(), - }), - }; - } - initialize.map(|_| Box::new(provider) as Box) -} - -fn mutual_protocol_version(supported: &[String]) -> Option<&'static str> { - wright_lpp::LPP_SUPPORTED_PROTOCOL_VERSIONS - .iter() - .rev() - .copied() - .find(|version| supported.iter().any(|s| s == version)) -} - fn provider_supports(provider: &dyn LanguageProvider, capability: Capability) -> bool { provider .capabilities() @@ -626,10 +610,6 @@ fn provider_supports(provider: &dyn LanguageProvider, capability: Capability) -> .unwrap_or(false) } -fn provider_failure_code(error: &ProviderError) -> &str { - error.refusal_code().unwrap_or_else(|| error.code()) -} - fn lpp_severity(severity: wright_lpp::DiagnosticSeverity) -> &'static str { match severity { wright_lpp::DiagnosticSeverity::Error => "error", diff --git a/crates/wright-language/tests/service.rs b/crates/wright-language/tests/service.rs index 52711ff8..2cbbeb88 100644 --- a/crates/wright-language/tests/service.rs +++ b/crates/wright-language/tests/service.rs @@ -4,16 +4,18 @@ use wright_language::document::{Document, Position, line_col_position, uri_to_pa use wright_language::{LanguageService, Range}; fn service() -> LanguageService { + // An explicit but nonexistent provider executable keeps provider + // resolution deterministic and offline: `resolve()` refuses it + // without consulting a store or the network, so `.opy` documents + // reach the structured provider boundary (#738, #1004). LanguageService::with_config( PathBuf::from("."), wright_language::SessionConfig { - opy_provider: wright_language::OpyProviderConfig { - executable: None, - store_dir: Some( - std::env::temp_dir() - .join(format!("wright-language-test-{}", std::process::id())), - ), - }, + opy_provider: wright_language::OpyProviderConfig::with_executable( + std::env::temp_dir() + .join(format!("wright-language-missing-{}", std::process::id())) + .join("opy-provider"), + ), ..wright_language::SessionConfig::default() }, ) @@ -84,8 +86,16 @@ fn source_documents_report_provider_boundary_without_static_fallback() { PathBuf::from("."), )); let diagnostics = service.diagnostics(&uri); + // `.opy` resolves a provider (the explicit executable above fails + // validation); `.ostw`/`.del` have no provider to resolve at all — + // both surface an explicit refusal, never a static fallback. + let expected = if ext == "opy" { + "provider-missing" + } else { + "source-provider-unavailable" + }; assert_eq!( - diagnostics[0].code, "source-provider-unavailable", + diagnostics[0].code, expected, "{ext} documents surface an explicit provider refusal" ); } @@ -337,9 +347,8 @@ fn non_workshop_documents_keep_their_boundaries() { Document::new(uri.to_string(), WORKSHOP_SOURCE, PathBuf::from(".")) .with_language_id("workshop"), ); - assert_eq!( - service.diagnostics(uri)[0].code, - "source-provider-unavailable", + assert!( + !service.diagnostics(uri).is_empty(), "the provider boundary wins over an extension tag" ); assert!( @@ -377,3 +386,88 @@ fn non_workshop_documents_keep_their_boundaries() { .is_none() ); } + +/// The language service's OPY provider path runs the shared connect path +/// (#1004): the provider session is spawned through `resolve()` (no +/// `installed()` fallback), `initialize` retries once at the highest +/// mutual version on `protocol-version-mismatch`, and a provider refusal +/// reaches the document surface under the driver's +/// `provider-refusal-` classification, not the raw refusal code. +#[cfg(unix)] +#[test] +fn an_opy_provider_negotiates_and_classifies_refusals_like_the_driver() { + use std::os::unix::fs::PermissionsExt; + + const PROVIDER: &str = r#"#!/usr/bin/env python3 +import json, sys, pathlib +log = pathlib.Path(__file__).resolve().parent.joinpath("init-log") +main = pathlib.Path(__file__).resolve().parent.joinpath("main.opy").as_uri() +def reply(id, result=None, error=None): + message = {"jsonrpc": "2.0", "id": id} + message.update({"error": error} if error else {"result": result}) + print(json.dumps(message), flush=True) +def lpp_error(id, kind, details, text): + reply(id, error={"code": -32000, "message": text, "data": {"lpp": {"kind": kind, "details": details}}}) +for line in sys.stdin: + request = json.loads(line) + id, method = request["id"], request["method"] + if method == "lpp/initialize": + version = request["params"]["protocolVersion"] + with open(log, "a") as f: + f.write(version + "\n") + if version == "1.0": + lpp_error(id, "protocolVersionMismatch", {"supportedProtocolVersions": ["1.2"]}, "unsupported") + else: + reply(id, {"protocolVersion": version, "serverInfo": {"name": "fake", "version": "0"}, + "languages": [{"id": "opy", "extensions": ["opy"]}], + "capabilities": {"check": True}}) + elif method == "lpp/check": + lpp_error(id, "refusal", {"refusalCode": "check.tooLarge"}, "the project is too large") + else: + reply(id, {}) +"#; + + let dir = std::env::temp_dir().join(format!( + "wright-language-provider-test-{}", + std::process::id() + )); + std::fs::create_dir_all(&dir).expect("temp directory"); + let script = dir.join("fake-provider"); + std::fs::write(&script, PROVIDER).expect("provider script"); + std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).expect("chmod"); + + let mut service = LanguageService::with_config( + dir.clone(), + wright_language::SessionConfig { + opy_provider: wright_language::OpyProviderConfig::with_executable(&script), + ..wright_language::SessionConfig::default() + }, + ); + let uri = url::Url::from_file_path(dir.join("main.opy")) + .expect("entry uri") + .to_string(); + service.store.open(Document::new( + uri.clone(), + "globalvar score = 0\n".to_string(), + dir.clone(), + )); + + let diagnostics = service.diagnostics(&uri); + assert_eq!(diagnostics.len(), 1, "{diagnostics:?}"); + assert_eq!( + diagnostics[0].code, "provider-refusal-check.tooLarge", + "the language service emits the driver's refusal classification" + ); + let attempts: Vec = std::fs::read_to_string(dir.join("init-log")) + .expect("init log") + .lines() + .map(str::to_string) + .collect(); + assert_eq!( + attempts, + vec!["1.0".to_string(), "1.2".to_string()], + "the rejected preferred version retries once at the highest mutual version" + ); + + std::fs::remove_dir_all(&dir).expect("remove test directory"); +} diff --git a/crates/wright-lsp/tests/lsp.rs b/crates/wright-lsp/tests/lsp.rs index 12adcece..d29a547c 100644 --- a/crates/wright-lsp/tests/lsp.rs +++ b/crates/wright-lsp/tests/lsp.rs @@ -33,6 +33,7 @@ impl LspClient { SPAWN_COUNTER.fetch_add(1, Ordering::Relaxed) )); std::fs::create_dir_all(&provider_dir).unwrap(); + command.env("WRIGHT_OPY_PROVIDER_BASE_URL", "http://127.0.0.1:1"); // offline let mut child = command .env("WRIGHT_PROVIDER_DATA_DIR", &provider_dir) .stdin(Stdio::piped()) @@ -158,7 +159,6 @@ fn initialize(client: &mut LspClient) -> serde_json::Value { }), ) } - #[test] fn lsp_binary_reports_the_implementation_version_non_interactively() { let output = Command::new(env!("CARGO_BIN_EXE_wright-lsp")) @@ -420,7 +420,6 @@ fn workshop_diagnostics_track_the_current_buffer() { assert!(shutdown["result"].is_null()); client.notify("exit", serde_json::json!(null)); } - #[test] fn opy_lsp_workflow_reports_provider_boundary_without_static_fallback() { let root = workspace_root(); @@ -440,7 +439,7 @@ fn opy_lsp_workflow_reports_provider_boundary_without_static_fallback() { ); let published = client.read_notification("textDocument/publishDiagnostics"); let diagnostics = published["params"]["diagnostics"].as_array().unwrap(); - assert_eq!(diagnostics[0]["code"], "source-provider-unavailable"); + assert_eq!(diagnostics[0]["code"], "provider-offline"); let completion = client.request( 2, @@ -579,9 +578,6 @@ fn rename_without_a_configured_provider_is_a_structured_request_error() { #[test] fn rename_is_only_negotiated_for_versioned_workspace_edits() { - // `WorkspaceEdit.changes` cannot carry the validated document version, - // so a client without `documentChanges` gets neither the advertisement - // nor the edit. let root = workspace_root(); let mut client = LspClient::spawn(&root); let init = client.request( diff --git a/docs/language-services.md b/docs/language-services.md index b394466d..32250360 100644 --- a/docs/language-services.md +++ b/docs/language-services.md @@ -60,9 +60,11 @@ or changing an `.opy` document publishes the provider's `lpp/check` answer over the complete open-buffer overlay — the unsaved text under the client's own URI — with the provider's UTF-16 ranges carried through (#738). DEL/OSTW documents and OPY documents without a working provider publish an explicit -`source-provider-unavailable` error, and a failed provider request publishes -its structured error code on the document. A document that is neither -publishes no diagnostics. +`source-provider-unavailable` or provider-resolution error code, and a +failed provider request publishes its structured error code on the +document — a provider refusal carries the same `provider-refusal-` +classification the driver surfaces report (#1004). A document that is +neither publishes no diagnostics. `textDocument/rename` on a source-language document routes through the provider-owned mutation path (#156): the From e2d731be294dec7e35384f00083c93f752ffd2c6 Mon Sep 17 00:00:00 2001 From: Teakowa <27560638+Teakowa@users.noreply.github.com> Date: Tue, 27 Oct 2026 21:15:21 +0800 Subject: [PATCH 2/2] fix: stop classifying initialize refusals and session failures as provider refusals Address review findings on #1014: - `lookup`'s connect `Err` arm reclassified an `lpp/initialize` LPP refusal into an `unavailable` capability gap, dropping the refusal code. LPP answers that are not negotiation gaps now carry through the shared `provider-refusal-` classification; only resolution, spawn, process, and version-negotiation failures stay `unavailable`. - `LanguageService::session()` fabricated a `Refusal` `ProviderError` from a `CompilerSession::new` diagnostic, publishing `provider-refusal-invalid-selection` for a Wright-side failure. The latched connect failure is now a classified `(code, message)` pair, so a construction diagnostic keeps its own code on `.opy` diagnostics and rename while provider errors still classify through `provider_failure_code`. - `docs/language-services.md` still claimed the provider session never downloads at `initialize`; it now documents the `resolve()` path (explicit executable, installed store, or bootstrap download). --- crates/wright-driver/src/lookup.rs | 34 +++++++++++-- crates/wright-driver/tests/lookup.rs | 32 ++++++++++++ crates/wright-language/src/service.rs | 66 ++++++++++++------------- crates/wright-language/tests/service.rs | 41 +++++++++++++++ docs/language-services.md | 15 +++--- 5 files changed, 143 insertions(+), 45 deletions(-) diff --git a/crates/wright-driver/src/lookup.rs b/crates/wright-driver/src/lookup.rs index 1bafafa4..4c64ce1c 100644 --- a/crates/wright-driver/src/lookup.rs +++ b/crates/wright-driver/src/lookup.rs @@ -529,12 +529,36 @@ fn opy_lookup( Some(crate::ConnectFloor::Entry), ) { Ok(connected) => connected.provider, - // A connect failure — an unresolvable provider, a refused - // handshake, a version mismatch — is a capability gap on this - // surface, the same contract the spawn/initialize failures - // reported before the shared connect path (#1004). + // The failures `unavailable` covered before the shared connect + // path (#1004): resolution, spawn, process startup, and version + // negotiation — all capability gaps on this surface. A provider's + // `lpp/initialize` refusal instead keeps its structured code + // through the shared `provider-refusal-` classification. Err(error) => { - return unavailable("opy", OWNER_OPY, LOOKUP_CAPABILITY, error.to_string()); + let gap = matches!( + &error, + wright_lpp::ProviderError::NotConfigured { .. } + | wright_lpp::ProviderError::Spawn { .. } + | wright_lpp::ProviderError::Io { .. } + | wright_lpp::ProviderError::Exited { .. } + | wright_lpp::ProviderError::Local { .. } + | wright_lpp::ProviderError::ProtocolVersionMismatch { .. } + ) || matches!( + &error, + wright_lpp::ProviderError::Lpp(error) + if matches!( + error.kind, + wright_lpp::LppErrorKind::ProtocolVersionMismatch + | wright_lpp::LppErrorKind::CapabilityUnavailable + ) + ); + if gap { + return unavailable("opy", OWNER_OPY, LOOKUP_CAPABILITY, error.to_string()); + } + return carried_refusal( + &crate::source_provider::provider_failure_code(&error), + error.to_string(), + ); } }; let negotiated = provider diff --git a/crates/wright-driver/tests/lookup.rs b/crates/wright-driver/tests/lookup.rs index 94ac3e6a..17d9129f 100644 --- a/crates/wright-driver/tests/lookup.rs +++ b/crates/wright-driver/tests/lookup.rs @@ -589,6 +589,22 @@ for line in sys.stdin: reply(id, {}) "#; +#[cfg(unix)] +const INIT_REFUSAL_PROVIDER: &str = r#"#!/usr/bin/env python3 +import json, sys + +for line in sys.stdin: + request = json.loads(line) + id, method = request["id"], request["method"] + if method == "lpp/initialize": + print(json.dumps({"jsonrpc": "2.0", "id": id, "error": { + "code": -32000, "message": "refusal: busy", + "data": {"lpp": {"kind": "refusal", + "details": {"refusalCode": "init.tooBusy"}}}}}), flush=True) + else: + print(json.dumps({"jsonrpc": "2.0", "id": id, "result": {}}), flush=True) +"#; + #[cfg(unix)] const NO_LOOKUP_PROVIDER: &str = r#"#!/usr/bin/env python3 import json, sys @@ -714,6 +730,22 @@ fn opy_lookup_reports_unavailable_when_the_provider_lacks_the_capability() { let _ = std::fs::remove_dir_all(dir); } +#[cfg(unix)] +#[test] +fn opy_lookup_carries_an_initialize_refusal_with_its_code() { + let (dir, mut service) = service_with_provider(INIT_REFUSAL_PROVIDER); + let error = match service.handle(&lookup_request("opy", Some("hud"))) { + ToolResponse::Error { error } => error, + ToolResponse::Ok { result } => panic!("expected a refusal, got {result:?}"), + }; + // The shared provider-failure classification (#1004): the same + // `provider-refusal-` the driver surfaces report, not an + // Ok `unavailable` that reclassifies the refusal as a capability gap. + assert_eq!(error.code, "provider-refusal-init.tooBusy"); + + let _ = std::fs::remove_dir_all(dir); +} + #[test] fn workshop_lookup_reports_parameter_content_facts() { let mut owned = session(workshop_path()); diff --git a/crates/wright-language/src/service.rs b/crates/wright-language/src/service.rs index 2f751b53..47c2818d 100644 --- a/crates/wright-language/src/service.rs +++ b/crates/wright-language/src/service.rs @@ -5,7 +5,7 @@ use serde::Serialize; use wright_analyzer::canonical::{PositionResolution, ReferenceKind, SemanticIndex, Symbol}; use wright_driver::opy_provider::OPY_LANGUAGE_ID; use wright_driver::{CompilerSession, InputSpec, SessionConfig, SourceKind}; -use wright_lpp::{Capability, LanguageProvider, ProviderError}; +use wright_lpp::{Capability, LanguageProvider}; use crate::document::{Document, DocumentStore, Position, Range, line_col_position, uri_to_path}; @@ -89,7 +89,9 @@ pub struct HoverInfo { enum OpyProviderState { Pending, Ready(Box), - Unavailable(ProviderError), + // The `(code, message)` a connect failure reports — provider error + // classified, or a session-construction diagnostic verbatim (#1004). + Unavailable(String, String), } pub struct LanguageService { @@ -210,12 +212,7 @@ impl LanguageService { }; let provider = match self.opy_provider() { Ok(provider) => provider, - Err(error) => { - return boundary( - &wright_driver::source_provider::provider_failure_code(&error), - error.to_string(), - ); - } + Err((code, message)) => return boundary(&code, message), }; if !provider_supports(provider, Capability::Check) { return boundary( @@ -426,10 +423,10 @@ impl LanguageService { sources, }; - if let Err(error) = self.session() { + if let Err(diagnostic) = self.session() { return RenameOutcome::Refused { - code: wright_driver::source_provider::provider_failure_code(&error), - message: error.to_string(), + code: diagnostic.code, + message: diagnostic.message, }; } let mutation = self @@ -512,55 +509,58 @@ impl LanguageService { Some(SemanticIndex::build(&loaded.program)) } - fn opy_provider(&mut self) -> Result<&mut dyn LanguageProvider, ProviderError> { + /// The connected OPY provider, or the classified `(code, message)` + /// its connect failure latched. + fn opy_provider(&mut self) -> Result<&mut dyn LanguageProvider, (String, String)> { if matches!(self.opy, OpyProviderState::Pending) { self.opy = match self.connect_opy_provider() { Ok(provider) => OpyProviderState::Ready(provider), - Err(error) => OpyProviderState::Unavailable(error), + Err(failure) => OpyProviderState::Unavailable(failure.0, failure.1), }; } match &mut self.opy { OpyProviderState::Ready(provider) => Ok(provider.as_mut()), - OpyProviderState::Unavailable(error) => Err(error.clone()), + OpyProviderState::Unavailable(code, message) => Err((code.clone(), message.clone())), OpyProviderState::Pending => unreachable!("resolved above"), } } /// The shared session, built lazily on the first provider request so a - /// service that only serves Workshop documents never constructs one. - fn session(&mut self) -> Result<&CompilerSession, ProviderError> { + /// service that only serves Workshop documents never constructs one. A + /// construction failure is Wright's own diagnostic, not a provider + /// refusal: it surfaces with its own code (#1004). + fn session(&mut self) -> Result<&CompilerSession, wright_driver::Diagnostic> { if self.session.is_none() { - match CompilerSession::new(self.config.clone()) { - Ok(session) => self.session = Some(session), - Err(diagnostic) => { - return Err(ProviderError::lpp( - wright_lpp::LppErrorKind::Refusal, - serde_json::json!({ "refusalCode": diagnostic.code }), - diagnostic.message, - )); - } - } + self.session = Some(CompilerSession::new(self.config.clone())?); } Ok(self.session.as_ref().expect("session built above")) } /// The OPY provider session, connected through the driver's shared - /// connect path (#1004): `resolve()` lookup (no `installed()` - /// fallback), preferred-version initialize, and the LPP §19.2 retry. - fn connect_opy_provider(&mut self) -> Result, ProviderError> { + /// connect path (#1004). The `(code, message)` failure the surface + /// reports is a provider error classified through + /// `provider_failure_code`, or a session-construction diagnostic + /// carried verbatim. + fn connect_opy_provider(&mut self) -> Result, (String, String)> { let client = wright_lpp::ClientInfo { name: wright_lpp::LPP_CLIENT_NAME.to_string(), version: env!("CARGO_PKG_VERSION").to_string(), }; - Ok(self - .session()? + self.session() + .map_err(|d| (d.code.clone(), d.message.clone()))? .connect_provider( OPY_LANGUAGE_ID, &client, wright_lpp::LPP_PROTOCOL_VERSION, None, - )? - .provider) + ) + .map(|connected| connected.provider) + .map_err(|e| { + ( + wright_driver::source_provider::provider_failure_code(&e), + e.to_string(), + ) + }) } fn opy_document(&self, uri: &str) -> Option { diff --git a/crates/wright-language/tests/service.rs b/crates/wright-language/tests/service.rs index 2cbbeb88..3b15a637 100644 --- a/crates/wright-language/tests/service.rs +++ b/crates/wright-language/tests/service.rs @@ -471,3 +471,44 @@ for line in sys.stdin: std::fs::remove_dir_all(&dir).expect("remove test directory"); } + +/// A `CompilerSession::new` failure is Wright's own diagnostic, not a +/// provider refusal: an invalid lint selection in the service config +/// publishes `invalid-selection` on `.opy` diagnostics and rename — never +/// the fabricated `provider-refusal-invalid-selection` (#1004). +#[test] +fn a_session_construction_failure_keeps_its_own_code() { + let mut service = LanguageService::with_config( + PathBuf::from("."), + wright_language::SessionConfig { + selection: wright_driver::select::FindingSelection { + rule: Some("no-such-rule".to_string()), + ..wright_driver::select::FindingSelection::default() + }, + ..wright_language::SessionConfig::default() + }, + ); + let uri = "file:///workspace/main.opy"; + service.store.open(Document::new( + uri.to_string(), + "globalvar score = 0\n".to_string(), + PathBuf::from("."), + )); + + let diagnostics = service.diagnostics(uri); + assert_eq!(diagnostics.len(), 1, "{diagnostics:?}"); + assert_eq!(diagnostics[0].code, "invalid-selection"); + + let outcome = service.rename( + uri, + Position { + line: 0, + character: 0, + }, + "renamed", + ); + let wright_language::RenameOutcome::Refused { code, .. } = outcome else { + panic!("expected a refusal, got applied edits"); + }; + assert_eq!(code, "invalid-selection"); +} diff --git a/docs/language-services.md b/docs/language-services.md index 32250360..a88c3475 100644 --- a/docs/language-services.md +++ b/docs/language-services.md @@ -100,13 +100,14 @@ document answers `null` to hover, and `.del`/`.ostw` documents answer ## OPY provider-backed services (#738) `.opy` documents are served by the negotiated OPY provider, never by an -in-process OPY implementation. The provider session is resolved locally at -`initialize` — an explicit `--opy-provider ` executable or the -already-installed provider store, never a download — and `lpp/initialize` -runs inside the LSP handshake so the advertised surface is truthful. The -session is retained and shared by every later request; an unresolvable or -failed provider latches as unavailable and the LSP session continues without -OPY backing. +in-process OPY implementation. The provider session resolves at +`initialize` through the same `resolve()` path the driver uses — an +explicit `--opy-provider ` executable, the installed provider +store, or a bootstrap download of the released provider — and +`lpp/initialize` runs inside the LSP handshake so the advertised surface +is truthful. The session is retained and shared by every later request; an +unresolvable or failed provider latches as unavailable and the LSP session +continues without OPY backing. Every provider request sends the open-document overlay: the text the editor holds, at the buffer's version, under the URI the client opened — including