diff --git a/crates/wright-driver/src/config.rs b/crates/wright-driver/src/config.rs index f620666..816f093 100644 --- a/crates/wright-driver/src/config.rs +++ b/crates/wright-driver/src/config.rs @@ -11,13 +11,14 @@ use std::path::PathBuf; pub use wright_analyzer::registry::LintConfig; +use crate::source_provider::SourceBackend; + /// The concrete input frontend to use, or automatic detection. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SourceKind { /// Detect from the input path extension or stdin content. Auto, - /// `.opy` source through the adapter bridge (the native frontend is the - /// default path). + /// `.opy` source through the explicitly selected backend. Opy, /// `.ostw` / `.del` source through the native OSTW frontend (#117). Ostw, @@ -102,6 +103,10 @@ pub struct SessionConfig { pub input: InputSpec, /// Frontend selection; `Auto` detects from path/stdin. pub kind: SourceKind, + /// The source implementation selected for source-language workflows. + /// Raw Workshop is always handled in-process; the provider backend is + /// currently reserved for the first-party OPY provider. + pub source_backend: SourceBackend, /// Workshop client-locale override (bypasses auto-detection). pub locale: Option, /// Include root for `.opy` inputs (defaults to the input's directory). @@ -137,6 +142,7 @@ impl Default for SessionConfig { SessionConfig { input: InputSpec::Stdin, kind: SourceKind::Auto, + source_backend: SourceBackend::Native, locale: None, root: None, output: None, diff --git a/crates/wright-driver/src/edit.rs b/crates/wright-driver/src/edit.rs index aa5fd22..f9286ae 100644 --- a/crates/wright-driver/src/edit.rs +++ b/crates/wright-driver/src/edit.rs @@ -775,6 +775,7 @@ fn resolved_input( text: main_text.to_string(), path: Some(main_path.to_path_buf()), root: root.to_path_buf(), + cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), display: crate::input::display_path(main_path), identity: crate::input_identity(main_text), origin: origin_for(kind, locale), diff --git a/crates/wright-driver/src/input.rs b/crates/wright-driver/src/input.rs index 6f10ba1..7c1003e 100644 --- a/crates/wright-driver/src/input.rs +++ b/crates/wright-driver/src/input.rs @@ -26,6 +26,8 @@ pub struct ResolvedInput { pub path: Option, /// The include root (`.opy` include base); the input's directory by default. pub root: PathBuf, + /// The invocation working directory used to resolve a relative entry. + pub cwd: PathBuf, /// A stable display identity used in diagnostics (`` for stdin). pub display: String, /// SHA-256 hex of the input bytes (deterministic input identity). @@ -55,7 +57,19 @@ pub fn resolve(config: &SessionConfig) -> Result { } fn resolve_path(path: &Path, config: &SessionConfig) -> Result { - let bytes = std::fs::read(path).map_err(|error| { + let cwd = std::env::current_dir().map_err(|error| { + Diagnostic::error( + "cwd-io", + Stage::Discovery, + format!("cannot determine the invocation working directory: {error}"), + ) + })?; + let path = if path.is_absolute() { + path.to_path_buf() + } else { + cwd.join(path) + }; + let bytes = std::fs::read(&path).map_err(|error| { Diagnostic::error( "input-io", Stage::Discovery, @@ -64,23 +78,25 @@ fn resolve_path(path: &Path, config: &SessionConfig) -> Result kind_from_extension(path)?, + SourceKind::Auto => kind_from_extension(&path)?, other => other, }; let root = match &config.root { - Some(root) => root.clone(), + Some(root) if root.is_absolute() => root.clone(), + Some(root) => cwd.join(root), None => path .parent() .map(Path::to_path_buf) .unwrap_or_else(|| PathBuf::from(".")), }; - let display = display_path(path); + let display = display_path(&path); let origin = origin_for(kind, config.locale.as_deref()); Ok(ResolvedInput { kind, text, path: Some(path.to_path_buf()), root, + cwd, display, identity: sha256_hex(&bytes), origin, @@ -111,6 +127,7 @@ fn resolve_stdin(config: &SessionConfig) -> Result { .root .clone() .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))); + let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); let display = "".to_string(); let origin = origin_for(kind, config.locale.as_deref()); Ok(ResolvedInput { @@ -118,6 +135,7 @@ fn resolve_stdin(config: &SessionConfig) -> Result { text, path: None, root, + cwd, display, identity: sha256_hex(&bytes), origin, diff --git a/crates/wright-driver/src/lib.rs b/crates/wright-driver/src/lib.rs index e6d58cf..51e7eb2 100644 --- a/crates/wright-driver/src/lib.rs +++ b/crates/wright-driver/src/lib.rs @@ -22,6 +22,7 @@ pub mod provider_edit; pub mod result; pub mod service; pub mod session; +pub mod source_provider; pub mod workshop_provider; pub use config::{InputSpec, LintConfig, OutputFormat, SessionConfig, SourceKind}; @@ -32,7 +33,11 @@ pub use result::{ AnalyzeResult, CheckResult, CompileResult, CompiledOutput, ConvertResult, ConvertTarget, Envelope, InspectResult, LintResult, RESULT_CONTRACT, }; -pub use session::{CompilerSession, Loaded}; +pub use session::{CompilerSession, Loaded, Provenance}; +pub use source_provider::{ + SourceBackend, SourceCompilation, SourceLanguage, SourceProvenance, SourceProvider, + SourceProviderError, SourceTarget, +}; pub use workshop_provider::WorkshopProvider; pub use wright_transform::Profile; diff --git a/crates/wright-driver/src/result.rs b/crates/wright-driver/src/result.rs index f9bb5ac..65ad152 100644 --- a/crates/wright-driver/src/result.rs +++ b/crates/wright-driver/src/result.rs @@ -79,6 +79,9 @@ pub fn exit_code_from(diagnostics: &[Diagnostic]) -> u8 { if diagnostic.code == "ostw-unsupported" { return exit::UNSUPPORTED; } + if diagnostic.code == "source-provider-unsupported" { + return exit::UNSUPPORTED; + } has_source_error = true; } if has_source_error { diff --git a/crates/wright-driver/src/session.rs b/crates/wright-driver/src/session.rs index a49434b..21af162 100644 --- a/crates/wright-driver/src/session.rs +++ b/crates/wright-driver/src/session.rs @@ -16,7 +16,7 @@ use wright_analyzer::registry::LintConfig; use wright_analyzer::service::{Origin as ServiceOrigin, Request, SemanticService}; use crate::WorkshopProvider; -use crate::config::{SessionConfig, SourceKind}; +use crate::config::{InputSpec, SessionConfig, SourceKind}; use crate::diag::{Diagnostic, Origin, Position, Severity, SourceSpan, Stage}; use crate::input::{self, ResolvedInput}; use crate::progress::{ProgressEvent, ProgressObserver, ProgressPhase, ProgressUnit}; @@ -25,6 +25,10 @@ use crate::result::{ Envelope, InspectResult, LintResult, OstwFileSummary, OstwProjectSummary, exit_code_from, version_info, }; +use crate::source_provider::{ + SourceBackend, SourceLanguage, SourceProvenance, SourceProvider, SourceProviderError, + SourceTarget, +}; use crate::{input_identity, opy}; /// A successfully loaded program with its input and origin metadata. @@ -44,6 +48,17 @@ pub struct Loaded { pub origin: Origin, /// The resolved input. pub input: ResolvedInput, + /// Whether semantic spans can be mapped to authored source files. + pub provenance: Provenance, +} + +/// Provenance of the semantic program handed to Wright's analyzer. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Provenance { + /// The program was parsed from the source files represented by `input`. + Source, + /// The program came from an unmapped provider-returned canonical artifact. + Unmapped, } /// One reusable compiler session. @@ -54,6 +69,7 @@ pub struct CompilerSession { loaded: Option, diagnostics: Vec, progress_observer: Option>, + source_provider: Option>, } impl CompilerSession { @@ -72,9 +88,25 @@ impl CompilerSession { loaded: None, diagnostics: Vec::new(), progress_observer: None, + source_provider: None, }) } + /// Build a session whose source-language workflow must use `provider`. + /// + /// The provider is injected at the product boundary; its transport and + /// source project model are not visible to the session. A provider failure + /// is surfaced as-is and never falls back to the native OPY path. + pub fn with_source_provider( + mut config: SessionConfig, + provider: Box, + ) -> Result { + config.source_backend = SourceBackend::Provider; + let mut session = Self::new(config)?; + session.source_provider = Some(provider); + Ok(session) + } + /// Attach a transport-neutral observer for real workflow phase events. pub fn set_progress_observer(&mut self, observer: Arc) { self.progress_observer = Some(observer); @@ -100,12 +132,34 @@ impl CompilerSession { if let Some(loaded) = &self.loaded { return Ok(loaded.clone()); } + if self.config.source_backend == SourceBackend::Provider + && matches!(self.config.input, InputSpec::Stdin) + { + return Err(SourceProviderError::Unsupported { + message: "provider-backed source workflows require an entry path; stdin has no entry identity".to_string(), + } + .diagnostic()); + } self.progress(ProgressEvent::new(ProgressPhase::InputResolution)); let mut resolved = input::resolve(&self.config)?; + if self.config.source_backend == SourceBackend::Provider && resolved.kind != SourceKind::Opy + { + return Err(Diagnostic::error( + "source-provider-kind", + Stage::Discovery, + format!( + "the provider backend currently supports only OPY input; got '{}'", + resolved.kind.as_str() + ), + )); + } if resolved.kind == SourceKind::Ostw { self.progress(ProgressEvent::new(ProgressPhase::ProjectLoading)); return self.load_ostw(&mut resolved); } + if self.config.source_backend == SourceBackend::Provider { + return self.load_from_source_provider(&mut resolved); + } let mut program = match resolved.kind { SourceKind::Workshop => { self.progress(ProgressEvent::new(ProgressPhase::Parsing)); @@ -174,6 +228,116 @@ impl CompilerSession { ostw_semantic: None, origin: resolved.origin.clone(), input: resolved, + provenance: Provenance::Source, + }; + self.loaded = Some(loaded.clone()); + Ok(loaded) + } + + /// Load a source-language result through the explicit product provider + /// seam, then hand its canonical Workshop text to `workshop-rs`. + fn load_from_source_provider( + &mut self, + resolved: &mut ResolvedInput, + ) -> Result { + let language = match resolved.kind { + SourceKind::Opy => SourceLanguage::Opy, + other => { + return Err(Diagnostic::error( + "source-provider-kind", + Stage::Discovery, + format!( + "the provider backend currently supports only OPY input; got '{}'", + other.as_str() + ), + )); + } + }; + let target = SourceTarget::new( + language, + resolved + .path + .clone() + .unwrap_or_else(|| Path::new("").to_path_buf()), + resolved.cwd.clone(), + ); + let Some(provider) = self.source_provider.as_mut() else { + return Err(SourceProviderError::NotConfigured { language }.diagnostic()); + }; + if provider.language() != language { + return Err(Diagnostic::error( + "source-provider-language", + Stage::Discovery, + format!( + "the injected provider serves '{}' but the selected source is '{}'", + provider.language().as_str(), + language.as_str() + ), + )); + } + let compilation = provider + .compile(&target) + .map_err(|error| error.diagnostic())?; + let mut provider_diagnostics = compilation.diagnostics; + if let Some(index) = provider_diagnostics + .iter() + .position(|diagnostic| diagnostic.severity == Severity::Error) + { + let first = provider_diagnostics.remove(index); + self.diagnostics.extend(provider_diagnostics); + return Err(first); + } + self.diagnostics.extend(provider_diagnostics); + let provenance = match compilation.provenance { + SourceProvenance::Unmapped => Provenance::Unmapped, + }; + let Some(workshop_text) = compilation.workshop_text else { + return Err(Diagnostic::error( + "source-provider-no-artifact", + Stage::Frontend, + "the source provider returned no canonical Workshop output", + )); + }; + let locale_name = compilation + .locale + .or_else(|| resolved.origin.locale.clone()) + .unwrap_or_else(|| "en-US".to_string()); + let locale = workshop_rs::catalog::Locale::new(&locale_name); + let program = workshop_rs::parser::parse_with_context( + &workshop_text, + &self.catalog, + &locale, + &self.catalog, + ) + .map_err(|error| workshop_diag_for_unmapped_provider_artifact(error, resolved))?; + self.progress(ProgressEvent::new(ProgressPhase::Validation)); + let mut program = program; + program.validate().map_err(|error| { + ir_diag_for_unmapped_provider_artifact( + "validation-error", + Stage::Validation, + error, + resolved, + ) + })?; + if self.config.profile != wright_transform::Profile::Off { + self.progress(ProgressEvent::new(ProgressPhase::Lowering)); + wright_transform::run(&mut program, self.config.profile).map_err(|error| { + Diagnostic::error( + "transform-error", + Stage::Internal, + format!("WIR transformation failed: {error}"), + ) + })?; + } + resolved.origin.locale = Some(locale.to_string()); + let loaded = Loaded { + program: Arc::new(program), + ostw: None, + ostw_semantic: None, + origin: resolved.origin.clone(), + input: resolved.clone(), + provenance, }; self.loaded = Some(loaded.clone()); Ok(loaded) @@ -208,6 +372,7 @@ impl CompilerSession { ostw_semantic: Some(Arc::new(semantic)), origin: resolved.origin.clone(), input: resolved.clone(), + provenance: Provenance::Source, }; self.loaded = Some(loaded.clone()); Ok(loaded) @@ -907,29 +1072,32 @@ pub(crate) fn resolve_finding_span_paths(findings: &mut serde_json::Value, loade if !span.is_object() { continue; } - let path = span - .get("file") - .and_then(serde_json::Value::as_u64) - .map(|file| { - if file == 0 { - root_relative(loaded.input.path.as_deref(), &loaded.input.root) - .unwrap_or_else(|| loaded.input.display.clone()) - } else { - loaded - .program - .files - .get(workshop_rs::source::FileId::from_index(file as usize)) - .map(|source_file| { - root_relative( - Some(&loaded.input.root.join(&source_file.path)), - &loaded.input.root, - ) + let path = if loaded.provenance == Provenance::Unmapped { + "".to_string() + } else { + span.get("file") + .and_then(serde_json::Value::as_u64) + .map(|file| { + if file == 0 { + root_relative(loaded.input.path.as_deref(), &loaded.input.root) + .unwrap_or_else(|| loaded.input.display.clone()) + } else { + loaded + .program + .files + .get(workshop_rs::source::FileId::from_index(file as usize)) + .map(|source_file| { + root_relative( + Some(&loaded.input.root.join(&source_file.path)), + &loaded.input.root, + ) + .unwrap_or_else(|| format!("")) + }) .unwrap_or_else(|| format!("")) - }) - .unwrap_or_else(|| format!("")) - } - }) - .unwrap_or_else(|| loaded.input.display.clone()); + } + }) + .unwrap_or_else(|| loaded.input.display.clone()) + }; span["path"] = serde_json::Value::String(path); } } @@ -1045,6 +1213,25 @@ fn workshop_diag(error: workshop_rs::WorkshopError, resolved: &ResolvedInput) -> } } +fn provider_artifact_origin(resolved: &ResolvedInput) -> Origin { + Origin { + kind: "provider-artifact".to_string(), + locale: resolved.origin.locale.clone(), + } +} + +fn workshop_diag_for_unmapped_provider_artifact( + error: workshop_rs::WorkshopError, + resolved: &ResolvedInput, +) -> Diagnostic { + let mut diagnostic = workshop_diag(error, resolved); + if let Some(span) = &mut diagnostic.span { + span.path = "".to_string(); + } + diagnostic.source = Some(provider_artifact_origin(resolved)); + diagnostic +} + /// Map a native frontend error to a driver diagnostic. /// /// Span paths resolve through the frontend file registry so a failure inside @@ -1256,3 +1443,14 @@ pub(crate) fn ir_diag( source: Some(resolved.origin.clone()), } } + +fn ir_diag_for_unmapped_provider_artifact( + code: &'static str, + stage: Stage, + error: wright_ir::error::IrError, + resolved: &ResolvedInput, +) -> Diagnostic { + let mut diagnostic = ir_diag(code, stage, error, resolved); + diagnostic.source = Some(provider_artifact_origin(resolved)); + diagnostic +} diff --git a/crates/wright-driver/src/source_provider.rs b/crates/wright-driver/src/source_provider.rs new file mode 100644 index 0000000..bc591da --- /dev/null +++ b/crates/wright-driver/src/source_provider.rs @@ -0,0 +1,202 @@ +//! Wright's product-facing source-provider boundary. +//! +//! The product layer selects a source target and receives source diagnostics +//! plus canonical Workshop text. Provider transport, document synchronization, +//! and compiler implementation types stay behind the adapter that implements +//! this trait. A provider owns project discovery from the selected entry; the +//! target deliberately carries no project graph or preloaded document set. + +use std::fmt; +use std::path::{Path, PathBuf}; + +use crate::diag::{Diagnostic, Stage}; + +/// A source language that can participate in the product boundary. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SourceLanguage { + /// OverPy, whose provider owns `#!mainFile`, includes, and preprocessing. + Opy, +} + +impl SourceLanguage { + /// The stable product spelling. + pub const fn as_str(self) -> &'static str { + match self { + SourceLanguage::Opy => "opy", + } + } +} + +/// Whether a source language is loaded natively or through an injected +/// provider. The choice is explicit so provider failures cannot select a +/// different implementation by accident. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum SourceBackend { + /// Use the implementation linked into Wright for the selected source kind. + #[default] + Native, + /// Require the explicitly injected source provider. + Provider, +} + +/// The user-selected source target passed to a provider. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SourceTarget { + /// The language selected by the product layer. + pub language: SourceLanguage, + /// The one path selected by the user, resolved relative to [`cwd`]. + pub entry: PathBuf, + /// The invocation working directory used to resolve relative CLI paths. + pub cwd: PathBuf, +} + +/// The provenance contract for the canonical Workshop result. +/// +/// The current provider boundary has no canonical-Workshop-to-authored-source +/// span map, so provider results remain explicitly unmapped. A mapped variant +/// must not be added without carrying and consuming the actual mapping data. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SourceProvenance { + /// The canonical artifact has no authored-source mapping. + Unmapped, +} + +impl SourceTarget { + /// Construct an entry target and resolve a relative path from `cwd`. + pub fn new( + language: SourceLanguage, + entry: impl Into, + cwd: impl Into, + ) -> Self { + let cwd = cwd.into(); + let entry = entry.into(); + let entry = if entry.is_absolute() { + entry + } else { + cwd.join(entry) + }; + Self { + language, + entry, + cwd, + } + } + + /// The entry path as a [`Path`]. + pub fn entry_path(&self) -> &Path { + &self.entry + } +} + +/// The result of a provider-owned source compilation. +#[derive(Debug, Clone, PartialEq)] +pub struct SourceCompilation { + /// Canonical Workshop source returned by the provider, when compilation + /// succeeded. The driver validates and parses this text through + /// `workshop-rs`; it never consumes provider-internal IR. + pub workshop_text: Option, + /// Workshop client locale for the returned canonical source. + pub locale: Option, + /// Whether canonical Workshop spans can be mapped to authored source. + pub provenance: SourceProvenance, + /// Diagnostics already attributed to their authored source files by the + /// provider adapter. These are preserved alongside Wright diagnostics. + pub diagnostics: Vec, +} + +impl SourceCompilation { + /// A successful canonical Workshop result without diagnostics. + pub fn success(workshop_text: impl Into) -> Self { + Self { + workshop_text: Some(workshop_text.into()), + locale: None, + provenance: SourceProvenance::Unmapped, + diagnostics: Vec::new(), + } + } +} + +/// A failure at the source-provider boundary, distinct from a source +/// diagnostic returned by the provider. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SourceProviderError { + /// The requested provider was not injected for a provider-backed session. + NotConfigured { language: SourceLanguage }, + /// The provider cannot perform the requested product operation. + Unsupported { message: String }, + /// The provider or its process failed independently of source contents. + Failed { code: String, message: String }, +} + +impl SourceProviderError { + /// Stable machine-readable error code. + pub fn code(&self) -> &str { + match self { + SourceProviderError::NotConfigured { .. } => "source-provider-not-configured", + SourceProviderError::Unsupported { .. } => "source-provider-unsupported", + SourceProviderError::Failed { code, .. } => code, + } + } + + /// Convert the boundary failure to the driver's structured diagnostic. + pub fn diagnostic(&self) -> Diagnostic { + let message = self.to_string(); + let stage = match self { + SourceProviderError::Unsupported { .. } => Stage::Frontend, + SourceProviderError::NotConfigured { .. } | SourceProviderError::Failed { .. } => { + Stage::Internal + } + }; + Diagnostic::error(self.code(), stage, message) + } +} + +impl fmt::Display for SourceProviderError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + SourceProviderError::NotConfigured { language } => write!( + formatter, + "no source provider is configured for '{}'", + language.as_str() + ), + SourceProviderError::Unsupported { message } + | SourceProviderError::Failed { message, .. } => formatter.write_str(message), + } + } +} + +impl std::error::Error for SourceProviderError {} + +/// The provider implementation consumed by the product layer. +pub trait SourceProvider { + /// The source language served by this provider. + fn language(&self) -> SourceLanguage; + + /// Compile the user-selected entry. Project discovery and source closure + /// are owned by the implementation behind this boundary. + fn compile(&mut self, target: &SourceTarget) -> Result; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn relative_entry_is_resolved_from_the_invocation_directory() { + let target = SourceTarget::new(SourceLanguage::Opy, "src/main.opy", "/project"); + assert_eq!(target.entry, PathBuf::from("/project/src/main.opy")); + assert_eq!(target.cwd, PathBuf::from("/project")); + } + + #[test] + fn provider_failures_have_a_distinct_structured_code() { + let diagnostic = SourceProviderError::Failed { + code: "provider-exited".to_string(), + message: "provider exited".to_string(), + } + .diagnostic(); + assert_eq!(diagnostic.code, "provider-exited"); + assert_eq!(diagnostic.stage, Stage::Internal); + assert!(diagnostic.span.is_none()); + } +} diff --git a/crates/wright-driver/tests/source_provider.rs b/crates/wright-driver/tests/source_provider.rs new file mode 100644 index 0000000..be1c47b --- /dev/null +++ b/crates/wright-driver/tests/source_provider.rs @@ -0,0 +1,214 @@ +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; + +use wright_driver::source_provider::{ + SourceCompilation, SourceLanguage, SourceProvider, SourceProviderError, SourceTarget, +}; +use wright_driver::{CompilerSession, InputSpec, SessionConfig, SourceBackend, SourceKind}; + +fn workspace_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../..") +} + +fn workshop_fixture(fixture: &str) -> String { + let oracle = std::fs::read_to_string( + workspace_root().join(format!("compatibility/fixtures/{fixture}/oracle.json")), + ) + .expect("fixture oracle"); + serde_json::from_str::(&oracle) + .expect("oracle JSON") + .pointer("/compile/workshop") + .and_then(serde_json::Value::as_str) + .expect("Workshop artifact") + .to_string() +} + +fn temp_entry() -> (PathBuf, PathBuf) { + use std::sync::atomic::{AtomicUsize, Ordering}; + static COUNTER: AtomicUsize = AtomicUsize::new(0); + let dir = std::env::temp_dir().join(format!( + "wright-source-provider-test-{}-{}", + std::process::id(), + COUNTER.fetch_add(1, Ordering::SeqCst) + )); + std::fs::create_dir_all(&dir).expect("temp directory"); + let entry = dir.join("main.opy"); + std::fs::write(&entry, "this is intentionally not native OPY").expect("entry source"); + (dir, entry) +} + +fn cleanup(dir: PathBuf) { + #[cfg(target_os = "macos")] + { + let status = std::process::Command::new("trash") + .arg(&dir) + .status() + .expect("trash command"); + assert!(status.success(), "trash failed for {}", dir.display()); + } + #[cfg(not(target_os = "macos"))] + { + std::fs::remove_dir_all(dir).expect("remove test directory"); + } +} + +struct RecordingProvider { + target: Arc>>, + compilation: Option, + failure: Option, +} + +impl SourceProvider for RecordingProvider { + fn language(&self) -> SourceLanguage { + SourceLanguage::Opy + } + + fn compile(&mut self, target: &SourceTarget) -> Result { + *self.target.lock().expect("target lock") = Some(target.clone()); + if let Some(error) = self.failure.take() { + return Err(error); + } + Ok(self.compilation.take().expect("provider result")) + } +} + +#[test] +fn provider_backend_passes_only_the_selected_entry_and_uses_canonical_workshop_handoff() { + let (dir, entry) = temp_entry(); + let observed = Arc::new(Mutex::new(None)); + let provider = RecordingProvider { + target: Arc::clone(&observed), + compilation: Some(SourceCompilation::success(workshop_fixture( + "synthetic/control-flow", + ))), + failure: None, + }; + let config = SessionConfig { + input: InputSpec::Path(entry.clone()), + kind: SourceKind::Opy, + ..SessionConfig::default() + }; + let mut session = CompilerSession::with_source_provider(config, Box::new(provider)) + .expect("provider session"); + let result = session.compile(); + assert!(result.ok, "provider compile: {:?}", result.diagnostics); + assert!(result.result.output.is_some()); + let lint = session.lint(); + let findings = lint.result.findings.as_array().expect("finding array"); + assert!(!findings.is_empty(), "fixture supplies a lint finding"); + assert!(findings.iter().all(|finding| { + finding.pointer("/span/path") + == Some(&serde_json::Value::String( + "".to_string(), + )) + })); + let target = observed + .lock() + .expect("target lock") + .clone() + .expect("provider target"); + assert_eq!(target.language, SourceLanguage::Opy); + assert_eq!(target.entry, entry); + assert_eq!(target.cwd, std::env::current_dir().expect("cwd")); + assert_eq!( + session.load().expect("cached provider result").provenance, + wright_driver::Provenance::Unmapped + ); + cleanup(dir); +} + +#[test] +fn provider_backend_rejects_stdin_without_fabricating_an_entry() { + let config = SessionConfig { + input: InputSpec::Stdin, + kind: SourceKind::Opy, + source_backend: SourceBackend::Provider, + ..SessionConfig::default() + }; + let mut session = CompilerSession::new(config).expect("session"); + let result = session.check(); + assert!(!result.ok); + assert_eq!(result.exit, 3); + assert_eq!(result.diagnostics[0].code, "source-provider-unsupported"); +} + +#[test] +fn unmapped_provider_artifact_errors_do_not_claim_the_opy_entry() { + let (dir, entry) = temp_entry(); + let provider = RecordingProvider { + target: Arc::new(Mutex::new(None)), + compilation: Some(SourceCompilation::success( + "rule (\"broken\") {\n actions {\n UnknownAction;\n }\n}\n", + )), + failure: None, + }; + let config = SessionConfig { + input: InputSpec::Path(entry), + kind: SourceKind::Opy, + ..SessionConfig::default() + }; + let mut session = CompilerSession::with_source_provider(config, Box::new(provider)) + .expect("provider session"); + let result = session.check(); + assert!(!result.ok); + assert_eq!(result.diagnostics[0].code, "unknown-action"); + assert_eq!( + result.diagnostics[0] + .span + .as_ref() + .expect("artifact span") + .path, + "" + ); + assert_eq!( + result.diagnostics[0] + .source + .as_ref() + .expect("artifact origin") + .kind, + "provider-artifact" + ); + cleanup(dir); +} + +#[test] +fn provider_backend_does_not_fall_back_when_the_provider_fails() { + let (dir, entry) = temp_entry(); + let provider = RecordingProvider { + target: Arc::new(Mutex::new(None)), + compilation: None, + failure: Some(SourceProviderError::Failed { + code: "provider-exited".to_string(), + message: "provider exited before compiling the entry".to_string(), + }), + }; + let config = SessionConfig { + input: InputSpec::Path(entry), + kind: SourceKind::Opy, + ..SessionConfig::default() + }; + let mut session = CompilerSession::with_source_provider(config, Box::new(provider)) + .expect("provider session"); + let result = session.check(); + assert!(!result.ok); + assert_eq!(result.exit, 4); + assert_eq!(result.diagnostics[0].code, "provider-exited"); + cleanup(dir); +} + +#[test] +fn provider_backend_without_injection_is_an_explicit_failure() { + let (dir, entry) = temp_entry(); + let config = SessionConfig { + input: InputSpec::Path(entry), + kind: SourceKind::Opy, + source_backend: SourceBackend::Provider, + ..SessionConfig::default() + }; + let mut session = CompilerSession::new(config).expect("session"); + let result = session.check(); + assert!(!result.ok); + assert_eq!(result.exit, 4); + assert_eq!(result.diagnostics[0].code, "source-provider-not-configured"); + cleanup(dir); +} diff --git a/docs/architecture.md b/docs/architecture.md index 655a9f8..2748ac3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -110,9 +110,11 @@ or emitter. ### OverPy ```text -OPY source +user-selected entry target ↓ -opy-rs semantic implementation +Wright source-provider seam + ↓ +opy-rs provider owns project discovery ├─ standalone check / inspect / queries ├─ OPY-specific compiler behavior └─ canonical Workshop integration through workshop-rs @@ -124,6 +126,17 @@ Wright may consume native APIs and/or an LPP provider depending on the product boundary. It must not implement missing OPY syntax/semantic/compiler behavior in the integration layer merely to keep a Wright command working. +The product seam carries only the selected source language, entry path, and +invocation working directory. A provider owns `#!mainFile`, includes, +preprocessing, macros, and the source closure. The driver accepts provider +diagnostics and canonical Workshop text, validates that text through +`workshop-rs`, and then reuses the normal lint/analyze/compile pipeline. LPP +request/session/document types remain below the adapter. The current provider +result is explicitly `Unmapped`: canonical-artifact diagnostics and findings +use the `` identity until an actual canonical-to-authored +span map can be carried and consumed. A missing or failed provider is an +explicit failure and never selects the native OPY path. + ### DEL / OSTW ```text