Skip to main content

omena_resolver/
protocol.rs

1use super::*;
2
3const WORKSPACE_STYLE_URL_PREFIX: &str = "workspace:///";
4
5/// Reference context for the shared omena resolver protocol.
6#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
7#[serde(rename_all = "camelCase")]
8pub struct OmenaResolverReferenceContextV0 {
9    /// Workspace-relative style file that contains the reference.
10    pub referencing_file: String,
11}
12
13/// Canonical URL returned by `OmenaResolverV0::canonicalize`.
14#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
15#[serde(rename_all = "camelCase")]
16pub struct OmenaResolverCanonicalUrlV0 {
17    /// Stable canonical URL used as the resolver/SIF/lockfile key.
18    pub url: String,
19}
20
21impl OmenaResolverCanonicalUrlV0 {
22    /// Build a workspace-local canonical URL for a style source path.
23    pub fn workspace_style_path(path: &str) -> Self {
24        Self {
25            url: format!(
26                "{WORKSPACE_STYLE_URL_PREFIX}{}",
27                normalize_style_path(PathBuf::from(path))
28            ),
29        }
30    }
31
32    /// Return the workspace-local style path when this URL uses omena's
33    /// workspace scheme.
34    pub fn as_workspace_style_path(&self) -> Option<&str> {
35        self.url.strip_prefix(WORKSPACE_STYLE_URL_PREFIX)
36    }
37}
38
39/// Successful source load result for a canonical URL.
40#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
41#[serde(rename_all = "camelCase")]
42pub struct OmenaResolverLoadedSourceV0 {
43    /// Canonical URL that was loaded.
44    pub canonical_url: OmenaResolverCanonicalUrlV0,
45    /// Loaded UTF-8 source text.
46    pub source: String,
47}
48
49/// Five-state external boundary model used by the SIF migration path.
50#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
51#[serde(rename_all = "camelCase")]
52pub enum OmenaResolverBoundaryStateKindV0 {
53    Resolved,
54    Partial,
55    Stale,
56    Missing,
57    Unresolved,
58}
59
60impl OmenaResolverBoundaryStateKindV0 {
61    pub const fn as_str(self) -> &'static str {
62        match self {
63            Self::Resolved => "resolved",
64            Self::Partial => "partial",
65            Self::Stale => "stale",
66            Self::Missing => "missing",
67            Self::Unresolved => "unresolved",
68        }
69    }
70}
71
72/// Abstract value top used when a boundary is not fully resolved.
73#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
74#[serde(rename_all = "camelCase")]
75pub enum OmenaResolverBoundaryTopV0 {
76    /// The interface is known enough to keep diagnostics scoped to declared
77    /// exported symbols.
78    TopOpaque,
79    /// The interface is unknown and diagnostics must avoid false positives
80    /// over potentially valid external symbols.
81    TopAny,
82}
83
84impl OmenaResolverBoundaryTopV0 {
85    pub const fn as_str(self) -> &'static str {
86        match self {
87            Self::TopOpaque => "topOpaque",
88            Self::TopAny => "topAny",
89        }
90    }
91}
92
93/// Boundary-state witness for external-reference diagnostics.
94#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
95#[serde(rename_all = "camelCase")]
96pub struct OmenaResolverBoundaryStateV0 {
97    pub state: OmenaResolverBoundaryStateKindV0,
98    pub state_name: &'static str,
99    pub top: OmenaResolverBoundaryTopV0,
100    pub top_name: &'static str,
101    pub canonical_url: Option<OmenaResolverCanonicalUrlV0>,
102    pub reason: String,
103}
104
105impl OmenaResolverBoundaryStateV0 {
106    pub fn resolved(canonical_url: OmenaResolverCanonicalUrlV0) -> Self {
107        Self::new(
108            OmenaResolverBoundaryStateKindV0::Resolved,
109            OmenaResolverBoundaryTopV0::TopOpaque,
110            Some(canonical_url),
111            "resolved local or SIF-backed interface",
112        )
113    }
114
115    pub fn partial(reason: impl Into<String>) -> Self {
116        Self::new(
117            OmenaResolverBoundaryStateKindV0::Partial,
118            OmenaResolverBoundaryTopV0::TopAny,
119            None,
120            reason,
121        )
122    }
123
124    pub fn stale(canonical_url: OmenaResolverCanonicalUrlV0, reason: impl Into<String>) -> Self {
125        Self::new(
126            OmenaResolverBoundaryStateKindV0::Stale,
127            OmenaResolverBoundaryTopV0::TopAny,
128            Some(canonical_url),
129            reason,
130        )
131    }
132
133    pub fn missing(
134        canonical_url: Option<OmenaResolverCanonicalUrlV0>,
135        reason: impl Into<String>,
136    ) -> Self {
137        Self::new(
138            OmenaResolverBoundaryStateKindV0::Missing,
139            OmenaResolverBoundaryTopV0::TopAny,
140            canonical_url,
141            reason,
142        )
143    }
144
145    pub fn unresolved(reason: impl Into<String>) -> Self {
146        Self::new(
147            OmenaResolverBoundaryStateKindV0::Unresolved,
148            OmenaResolverBoundaryTopV0::TopAny,
149            None,
150            reason,
151        )
152    }
153
154    fn new(
155        state: OmenaResolverBoundaryStateKindV0,
156        top: OmenaResolverBoundaryTopV0,
157        canonical_url: Option<OmenaResolverCanonicalUrlV0>,
158        reason: impl Into<String>,
159    ) -> Self {
160        Self {
161            state,
162            state_name: state.as_str(),
163            top,
164            top_name: top.as_str(),
165            canonical_url,
166            reason: reason.into(),
167        }
168    }
169}
170
171pub fn omena_resolver_boundary_state_from_error_v0(
172    error: &OmenaResolverErrorV0,
173) -> OmenaResolverBoundaryStateV0 {
174    match error.kind {
175        OmenaResolverErrorKindV0::ExternalIgnored => {
176            OmenaResolverBoundaryStateV0::partial(error.message.clone())
177        }
178        OmenaResolverErrorKindV0::NotFound => {
179            OmenaResolverBoundaryStateV0::missing(None, error.message.clone())
180        }
181        OmenaResolverErrorKindV0::Unresolved
182        | OmenaResolverErrorKindV0::NetworkForbidden
183        | OmenaResolverErrorKindV0::UnsupportedCanonicalUrl => {
184            OmenaResolverBoundaryStateV0::unresolved(error.message.clone())
185        }
186    }
187}
188
189/// Surface the resolver-error channel for a reference whose canonical URL could not be
190/// canonicalized, then fold it onto the boundary lattice's `Unresolved` state (#34).
191///
192/// This is the cross-crate hop that lets the diagnostics layer reach the fifth boundary
193/// state without re-implementing the error taxonomy: a network reference is
194/// `NetworkForbidden` (the resolver never fetches it), and any other non-canonicalizable
195/// specifier is `Unresolved`. Both fold to a `top == TopAny`, `state == Unresolved`
196/// witness via the shared `omena_resolver_boundary_state_from_error_v0` mapper.
197pub fn omena_resolver_boundary_state_for_unresolved_reference_v0(
198    source: &str,
199) -> OmenaResolverBoundaryStateV0 {
200    let kind = if source.starts_with("http://") || source.starts_with("https://") {
201        OmenaResolverErrorKindV0::NetworkForbidden
202    } else {
203        OmenaResolverErrorKindV0::Unresolved
204    };
205    let error = OmenaResolverErrorV0::new(
206        kind,
207        format!("external reference `{source}` is not canonicalizable by the omena resolver"),
208    );
209    omena_resolver_boundary_state_from_error_v0(&error)
210}
211
212/// Error family for the shared resolver protocol.
213#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
214#[serde(rename_all = "camelCase")]
215pub enum OmenaResolverErrorKindV0 {
216    /// The reference is not resolvable from the current workspace snapshot.
217    Unresolved,
218    /// The reference is intentionally left at the existing external boundary.
219    ExternalIgnored,
220    /// Network references are never fetched by omena's resolver protocol.
221    NetworkForbidden,
222    /// The canonical URL is not loadable by this resolver implementation.
223    UnsupportedCanonicalUrl,
224    /// The canonical URL is valid but no local source is available.
225    NotFound,
226}
227
228impl OmenaResolverErrorKindV0 {
229    pub const fn as_str(self) -> &'static str {
230        match self {
231            Self::Unresolved => "unresolved",
232            Self::ExternalIgnored => "externalIgnored",
233            Self::NetworkForbidden => "networkForbidden",
234            Self::UnsupportedCanonicalUrl => "unsupportedCanonicalUrl",
235            Self::NotFound => "notFound",
236        }
237    }
238}
239
240/// Resolver protocol error.
241#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
242#[serde(rename_all = "camelCase")]
243pub struct OmenaResolverErrorV0 {
244    pub kind: OmenaResolverErrorKindV0,
245    pub kind_name: &'static str,
246    pub message: String,
247}
248
249impl OmenaResolverErrorV0 {
250    pub fn new(kind: OmenaResolverErrorKindV0, message: impl Into<String>) -> Self {
251        Self {
252            kind,
253            kind_name: kind.as_str(),
254            message: message.into(),
255        }
256    }
257}
258
259/// Shared resolver protocol for CLI, LSP, fixture, and query paths.
260///
261/// `canonicalize` must be deterministic over an immutable workspace snapshot
262/// and must not perform filesystem or network I/O. `load` may be implemented
263/// by local-disk-backed resolvers, but it must never fetch from the network.
264pub trait OmenaResolverV0 {
265    fn canonicalize(
266        &self,
267        context: &OmenaResolverReferenceContextV0,
268        raw_reference: &str,
269    ) -> Result<OmenaResolverCanonicalUrlV0, OmenaResolverErrorV0>;
270
271    fn load(
272        &self,
273        canonical_url: &OmenaResolverCanonicalUrlV0,
274    ) -> Result<OmenaResolverLoadedSourceV0, OmenaResolverErrorV0>;
275}
276
277/// Snapshot-backed resolver that adapts today's style module resolver to the
278/// RFC 0004 protocol without adding I/O to canonicalization.
279#[derive(Debug, Clone, Default, PartialEq, Eq)]
280pub struct OmenaResolverStyleModuleSnapshotV0 {
281    pub available_style_paths: BTreeSet<String>,
282    pub file_sources: BTreeMap<String, String>,
283    pub package_manifests: Vec<OmenaResolverStylePackageManifestV0>,
284    pub bundler_path_mappings: Vec<OmenaResolverBundlerPathAliasMappingV0>,
285    pub tsconfig_path_mappings: Vec<OmenaResolverTsconfigPathMappingV0>,
286}
287
288impl OmenaResolverStyleModuleSnapshotV0 {
289    pub fn new<I, S>(paths: I) -> Self
290    where
291        I: IntoIterator<Item = S>,
292        S: Into<String>,
293    {
294        Self {
295            available_style_paths: paths.into_iter().map(Into::into).collect(),
296            ..Self::default()
297        }
298    }
299
300    pub fn with_file_source(mut self, path: impl Into<String>, source: impl Into<String>) -> Self {
301        self.file_sources.insert(path.into(), source.into());
302        self
303    }
304
305    pub fn with_package_manifests(
306        mut self,
307        manifests: Vec<OmenaResolverStylePackageManifestV0>,
308    ) -> Self {
309        self.package_manifests = manifests;
310        self
311    }
312
313    pub fn with_bundler_path_mappings(
314        mut self,
315        mappings: Vec<OmenaResolverBundlerPathAliasMappingV0>,
316    ) -> Self {
317        self.bundler_path_mappings = mappings;
318        self
319    }
320
321    pub fn with_tsconfig_path_mappings(
322        mut self,
323        mappings: Vec<OmenaResolverTsconfigPathMappingV0>,
324    ) -> Self {
325        self.tsconfig_path_mappings = mappings;
326        self
327    }
328
329    fn available_style_path_refs(&self) -> BTreeSet<&str> {
330        self.available_style_paths
331            .iter()
332            .map(String::as_str)
333            .collect()
334    }
335}
336
337impl OmenaResolverV0 for OmenaResolverStyleModuleSnapshotV0 {
338    fn canonicalize(
339        &self,
340        context: &OmenaResolverReferenceContextV0,
341        raw_reference: &str,
342    ) -> Result<OmenaResolverCanonicalUrlV0, OmenaResolverErrorV0> {
343        if raw_reference.starts_with("http://") || raw_reference.starts_with("https://") {
344            return Err(OmenaResolverErrorV0::new(
345                OmenaResolverErrorKindV0::NetworkForbidden,
346                "omena resolver canonicalization never fetches network references",
347            ));
348        }
349
350        let available_style_paths = self.available_style_path_refs();
351        let resolution = summarize_omena_resolver_style_module_resolution_with_path_mappings(
352            &context.referencing_file,
353            raw_reference,
354            &available_style_paths,
355            self.package_manifests.as_slice(),
356            self.bundler_path_mappings.as_slice(),
357            self.tsconfig_path_mappings.as_slice(),
358        );
359
360        if let Some(path) = resolution.resolved_style_path {
361            return Ok(OmenaResolverCanonicalUrlV0::workspace_style_path(&path));
362        }
363
364        let kind = if resolution.resolution_kind == "externalIgnored" {
365            OmenaResolverErrorKindV0::ExternalIgnored
366        } else {
367            OmenaResolverErrorKindV0::Unresolved
368        };
369        Err(OmenaResolverErrorV0::new(
370            kind,
371            format!(
372                "could not canonicalize `{raw_reference}` from `{}`",
373                context.referencing_file
374            ),
375        ))
376    }
377
378    fn load(
379        &self,
380        canonical_url: &OmenaResolverCanonicalUrlV0,
381    ) -> Result<OmenaResolverLoadedSourceV0, OmenaResolverErrorV0> {
382        let Some(path) = canonical_url.as_workspace_style_path() else {
383            return Err(OmenaResolverErrorV0::new(
384                OmenaResolverErrorKindV0::UnsupportedCanonicalUrl,
385                format!("unsupported canonical URL `{}`", canonical_url.url),
386            ));
387        };
388        let Some(source) = self.file_sources.get(path) else {
389            return Err(OmenaResolverErrorV0::new(
390                OmenaResolverErrorKindV0::NotFound,
391                format!("no source snapshot for `{path}`"),
392            ));
393        };
394        Ok(OmenaResolverLoadedSourceV0 {
395            canonical_url: canonical_url.clone(),
396            source: source.clone(),
397        })
398    }
399}
400
401#[cfg(test)]
402mod tests {
403    use super::*;
404
405    #[test]
406    fn snapshot_resolver_canonicalizes_and_loads_relative_style_modules() -> Result<(), String> {
407        let resolver = OmenaResolverStyleModuleSnapshotV0::new(["src/Button.module.scss"])
408            .with_file_source("src/Button.module.scss", ".button { color: red; }");
409        let context = OmenaResolverReferenceContextV0 {
410            referencing_file: "src/App.module.scss".to_string(),
411        };
412
413        let canonical = resolver
414            .canonicalize(&context, "./Button.module.scss")
415            .map_err(|error| format!("expected canonical style URL: {error:?}"))?;
416        assert_eq!(canonical.url, "workspace:///src/Button.module.scss");
417
418        let loaded = resolver
419            .load(&canonical)
420            .map_err(|error| format!("expected loaded style source: {error:?}"))?;
421        assert_eq!(loaded.source, ".button { color: red; }");
422        Ok(())
423    }
424
425    #[test]
426    fn snapshot_resolver_forbids_network_references_during_canonicalization() -> Result<(), String>
427    {
428        let resolver = OmenaResolverStyleModuleSnapshotV0::new(["src/Button.module.scss"]);
429        let context = OmenaResolverReferenceContextV0 {
430            referencing_file: "src/App.module.scss".to_string(),
431        };
432
433        let error = match resolver.canonicalize(&context, "https://example.com/reset.css") {
434            Ok(canonical) => {
435                return Err(format!(
436                    "expected network reference to fail, got {canonical:?}"
437                ));
438            }
439            Err(error) => error,
440        };
441
442        assert_eq!(error.kind, OmenaResolverErrorKindV0::NetworkForbidden);
443        assert_eq!(error.kind_name, "networkForbidden");
444        Ok(())
445    }
446
447    #[test]
448    fn snapshot_resolver_reports_missing_snapshot_sources() -> Result<(), String> {
449        let resolver = OmenaResolverStyleModuleSnapshotV0::new(["src/Button.module.scss"]);
450        let context = OmenaResolverReferenceContextV0 {
451            referencing_file: "src/App.module.scss".to_string(),
452        };
453
454        let canonical = resolver
455            .canonicalize(&context, "./Button.module.scss")
456            .map_err(|error| format!("expected canonical style URL: {error:?}"))?;
457        let error = match resolver.load(&canonical) {
458            Ok(source) => return Err(format!("expected missing snapshot source, got {source:?}")),
459            Err(error) => error,
460        };
461
462        assert_eq!(error.kind, OmenaResolverErrorKindV0::NotFound);
463        Ok(())
464    }
465
466    #[test]
467    fn boundary_state_matrix_preserves_m7_external_states_and_top_semantics() {
468        let canonical = OmenaResolverCanonicalUrlV0::workspace_style_path("src/tokens.scss");
469        let states = [
470            OmenaResolverBoundaryStateV0::resolved(canonical.clone()),
471            OmenaResolverBoundaryStateV0::partial("external boundary kept in ignored mode"),
472            OmenaResolverBoundaryStateV0::stale(canonical.clone(), "lockfile hash drift"),
473            OmenaResolverBoundaryStateV0::missing(Some(canonical), "expected SIF is missing"),
474            OmenaResolverBoundaryStateV0::unresolved("specifier did not resolve"),
475        ];
476
477        assert_eq!(states[0].state_name, "resolved");
478        assert_eq!(states[0].top_name, "topOpaque");
479        assert_eq!(states[1].state_name, "partial");
480        assert_eq!(states[1].top_name, "topAny");
481        assert_eq!(states[2].state_name, "stale");
482        assert_eq!(states[2].top_name, "topAny");
483        assert_eq!(states[3].state_name, "missing");
484        assert_eq!(states[3].top_name, "topAny");
485        assert_eq!(states[4].state_name, "unresolved");
486        assert_eq!(states[4].top_name, "topAny");
487    }
488
489    #[test]
490    fn boundary_state_for_unresolved_reference_folds_to_unresolved_via_error_channel() {
491        // A non-canonicalizable bare/relative reference is `Unresolved`.
492        let bare = omena_resolver_boundary_state_for_unresolved_reference_v0("bootstrap");
493        assert_eq!(bare.state, OmenaResolverBoundaryStateKindV0::Unresolved);
494        assert_eq!(bare.top, OmenaResolverBoundaryTopV0::TopAny);
495        assert!(bare.reason.contains("bootstrap"));
496
497        // A network reference folds through `NetworkForbidden` to the same boundary state.
498        let network =
499            omena_resolver_boundary_state_for_unresolved_reference_v0("https://cdn.example/x.scss");
500        assert_eq!(network.state, OmenaResolverBoundaryStateKindV0::Unresolved);
501        assert_eq!(network.top, OmenaResolverBoundaryTopV0::TopAny);
502    }
503
504    #[test]
505    fn boundary_state_maps_existing_external_ignored_error_to_partial() {
506        let error = OmenaResolverErrorV0::new(
507            OmenaResolverErrorKindV0::ExternalIgnored,
508            "sass:map remains external in compatibility mode",
509        );
510
511        let state = omena_resolver_boundary_state_from_error_v0(&error);
512
513        assert_eq!(state.state, OmenaResolverBoundaryStateKindV0::Partial);
514        assert_eq!(state.top, OmenaResolverBoundaryTopV0::TopAny);
515        assert_eq!(
516            state.reason,
517            "sass:map remains external in compatibility mode"
518        );
519    }
520
521    #[test]
522    fn snapshot_resolver_preserves_tsconfig_path_mapping_resolution() -> Result<(), String> {
523        let resolver = OmenaResolverStyleModuleSnapshotV0::new([
524            "/fake/workspace/src/styles/Button.module.scss",
525        ])
526        .with_tsconfig_path_mappings(vec![OmenaResolverTsconfigPathMappingV0 {
527            base_path: "/fake/workspace".to_string(),
528            pattern: "@styles/*".to_string(),
529            target_patterns: vec!["src/styles/*".to_string()],
530        }]);
531        let context = OmenaResolverReferenceContextV0 {
532            referencing_file: "/fake/workspace/src/App.module.scss".to_string(),
533        };
534
535        let canonical = resolver
536            .canonicalize(&context, "@styles/Button")
537            .map_err(|error| format!("expected canonical style URL: {error:?}"))?;
538
539        assert_eq!(
540            canonical.as_workspace_style_path(),
541            Some("/fake/workspace/src/styles/Button.module.scss")
542        );
543        Ok(())
544    }
545
546    #[test]
547    fn snapshot_resolver_preserves_bundler_path_mapping_precedence() -> Result<(), String> {
548        let resolver = OmenaResolverStyleModuleSnapshotV0::new([
549            "/fake/workspace/src/bundler/Button.module.scss",
550            "/fake/workspace/src/tsconfig/Button.module.scss",
551        ])
552        .with_bundler_path_mappings(vec![OmenaResolverBundlerPathAliasMappingV0 {
553            pattern: "@styles".to_string(),
554            target_path: "/fake/workspace/src/bundler".to_string(),
555        }])
556        .with_tsconfig_path_mappings(vec![OmenaResolverTsconfigPathMappingV0 {
557            base_path: "/fake/workspace".to_string(),
558            pattern: "@styles/*".to_string(),
559            target_patterns: vec!["src/tsconfig/*".to_string()],
560        }]);
561        let context = OmenaResolverReferenceContextV0 {
562            referencing_file: "/fake/workspace/src/App.module.scss".to_string(),
563        };
564
565        let canonical = resolver
566            .canonicalize(&context, "@styles/Button")
567            .map_err(|error| format!("expected canonical style URL: {error:?}"))?;
568
569        assert_eq!(
570            canonical.as_workspace_style_path(),
571            Some("/fake/workspace/src/bundler/Button.module.scss")
572        );
573        Ok(())
574    }
575
576    #[test]
577    fn snapshot_resolver_preserves_package_manifest_resolution() -> Result<(), String> {
578        let resolver = OmenaResolverStyleModuleSnapshotV0::new([
579            "/fake/workspace/node_modules/@design/tokens/dist/theme.css",
580        ])
581        .with_package_manifests(vec![OmenaResolverStylePackageManifestV0 {
582            package_json_path: "/fake/workspace/node_modules/@design/tokens/package.json"
583                .to_string(),
584            package_json_source: r#"{"exports":{"./theme":{"style":"./dist/theme.css"}}}"#
585                .to_string(),
586        }]);
587        let context = OmenaResolverReferenceContextV0 {
588            referencing_file: "/fake/workspace/src/App.module.scss".to_string(),
589        };
590
591        let canonical = resolver
592            .canonicalize(&context, "@design/tokens/theme")
593            .map_err(|error| format!("expected canonical style URL: {error:?}"))?;
594
595        assert_eq!(
596            canonical.as_workspace_style_path(),
597            Some("/fake/workspace/node_modules/@design/tokens/dist/theme.css")
598        );
599        Ok(())
600    }
601}