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/// Error family for the shared resolver protocol.
190#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
191#[serde(rename_all = "camelCase")]
192pub enum OmenaResolverErrorKindV0 {
193    /// The reference is not resolvable from the current workspace snapshot.
194    Unresolved,
195    /// The reference is intentionally left at the existing external boundary.
196    ExternalIgnored,
197    /// Network references are never fetched by omena's resolver protocol.
198    NetworkForbidden,
199    /// The canonical URL is not loadable by this resolver implementation.
200    UnsupportedCanonicalUrl,
201    /// The canonical URL is valid but no local source is available.
202    NotFound,
203}
204
205impl OmenaResolverErrorKindV0 {
206    pub const fn as_str(self) -> &'static str {
207        match self {
208            Self::Unresolved => "unresolved",
209            Self::ExternalIgnored => "externalIgnored",
210            Self::NetworkForbidden => "networkForbidden",
211            Self::UnsupportedCanonicalUrl => "unsupportedCanonicalUrl",
212            Self::NotFound => "notFound",
213        }
214    }
215}
216
217/// Resolver protocol error.
218#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
219#[serde(rename_all = "camelCase")]
220pub struct OmenaResolverErrorV0 {
221    pub kind: OmenaResolverErrorKindV0,
222    pub kind_name: &'static str,
223    pub message: String,
224}
225
226impl OmenaResolverErrorV0 {
227    pub fn new(kind: OmenaResolverErrorKindV0, message: impl Into<String>) -> Self {
228        Self {
229            kind,
230            kind_name: kind.as_str(),
231            message: message.into(),
232        }
233    }
234}
235
236/// Shared resolver protocol for CLI, LSP, fixture, and query paths.
237///
238/// `canonicalize` must be deterministic over an immutable workspace snapshot
239/// and must not perform filesystem or network I/O. `load` may be implemented
240/// by local-disk-backed resolvers, but it must never fetch from the network.
241pub trait OmenaResolverV0 {
242    fn canonicalize(
243        &self,
244        context: &OmenaResolverReferenceContextV0,
245        raw_reference: &str,
246    ) -> Result<OmenaResolverCanonicalUrlV0, OmenaResolverErrorV0>;
247
248    fn load(
249        &self,
250        canonical_url: &OmenaResolverCanonicalUrlV0,
251    ) -> Result<OmenaResolverLoadedSourceV0, OmenaResolverErrorV0>;
252}
253
254/// Snapshot-backed resolver that adapts today's style module resolver to the
255/// RFC 0004 protocol without adding I/O to canonicalization.
256#[derive(Debug, Clone, Default, PartialEq, Eq)]
257pub struct OmenaResolverStyleModuleSnapshotV0 {
258    pub available_style_paths: BTreeSet<String>,
259    pub file_sources: BTreeMap<String, String>,
260    pub package_manifests: Vec<OmenaResolverStylePackageManifestV0>,
261    pub bundler_path_mappings: Vec<OmenaResolverBundlerPathAliasMappingV0>,
262    pub tsconfig_path_mappings: Vec<OmenaResolverTsconfigPathMappingV0>,
263}
264
265impl OmenaResolverStyleModuleSnapshotV0 {
266    pub fn new<I, S>(paths: I) -> Self
267    where
268        I: IntoIterator<Item = S>,
269        S: Into<String>,
270    {
271        Self {
272            available_style_paths: paths.into_iter().map(Into::into).collect(),
273            ..Self::default()
274        }
275    }
276
277    pub fn with_file_source(mut self, path: impl Into<String>, source: impl Into<String>) -> Self {
278        self.file_sources.insert(path.into(), source.into());
279        self
280    }
281
282    pub fn with_package_manifests(
283        mut self,
284        manifests: Vec<OmenaResolverStylePackageManifestV0>,
285    ) -> Self {
286        self.package_manifests = manifests;
287        self
288    }
289
290    pub fn with_bundler_path_mappings(
291        mut self,
292        mappings: Vec<OmenaResolverBundlerPathAliasMappingV0>,
293    ) -> Self {
294        self.bundler_path_mappings = mappings;
295        self
296    }
297
298    pub fn with_tsconfig_path_mappings(
299        mut self,
300        mappings: Vec<OmenaResolverTsconfigPathMappingV0>,
301    ) -> Self {
302        self.tsconfig_path_mappings = mappings;
303        self
304    }
305
306    fn available_style_path_refs(&self) -> BTreeSet<&str> {
307        self.available_style_paths
308            .iter()
309            .map(String::as_str)
310            .collect()
311    }
312}
313
314impl OmenaResolverV0 for OmenaResolverStyleModuleSnapshotV0 {
315    fn canonicalize(
316        &self,
317        context: &OmenaResolverReferenceContextV0,
318        raw_reference: &str,
319    ) -> Result<OmenaResolverCanonicalUrlV0, OmenaResolverErrorV0> {
320        if raw_reference.starts_with("http://") || raw_reference.starts_with("https://") {
321            return Err(OmenaResolverErrorV0::new(
322                OmenaResolverErrorKindV0::NetworkForbidden,
323                "omena resolver canonicalization never fetches network references",
324            ));
325        }
326
327        let available_style_paths = self.available_style_path_refs();
328        let resolution = summarize_omena_resolver_style_module_resolution_with_path_mappings(
329            &context.referencing_file,
330            raw_reference,
331            &available_style_paths,
332            self.package_manifests.as_slice(),
333            self.bundler_path_mappings.as_slice(),
334            self.tsconfig_path_mappings.as_slice(),
335        );
336
337        if let Some(path) = resolution.resolved_style_path {
338            return Ok(OmenaResolverCanonicalUrlV0::workspace_style_path(&path));
339        }
340
341        let kind = if resolution.resolution_kind == "externalIgnored" {
342            OmenaResolverErrorKindV0::ExternalIgnored
343        } else {
344            OmenaResolverErrorKindV0::Unresolved
345        };
346        Err(OmenaResolverErrorV0::new(
347            kind,
348            format!(
349                "could not canonicalize `{raw_reference}` from `{}`",
350                context.referencing_file
351            ),
352        ))
353    }
354
355    fn load(
356        &self,
357        canonical_url: &OmenaResolverCanonicalUrlV0,
358    ) -> Result<OmenaResolverLoadedSourceV0, OmenaResolverErrorV0> {
359        let Some(path) = canonical_url.as_workspace_style_path() else {
360            return Err(OmenaResolverErrorV0::new(
361                OmenaResolverErrorKindV0::UnsupportedCanonicalUrl,
362                format!("unsupported canonical URL `{}`", canonical_url.url),
363            ));
364        };
365        let Some(source) = self.file_sources.get(path) else {
366            return Err(OmenaResolverErrorV0::new(
367                OmenaResolverErrorKindV0::NotFound,
368                format!("no source snapshot for `{path}`"),
369            ));
370        };
371        Ok(OmenaResolverLoadedSourceV0 {
372            canonical_url: canonical_url.clone(),
373            source: source.clone(),
374        })
375    }
376}
377
378#[cfg(test)]
379mod tests {
380    use super::*;
381
382    #[test]
383    fn snapshot_resolver_canonicalizes_and_loads_relative_style_modules() -> Result<(), String> {
384        let resolver = OmenaResolverStyleModuleSnapshotV0::new(["src/Button.module.scss"])
385            .with_file_source("src/Button.module.scss", ".button { color: red; }");
386        let context = OmenaResolverReferenceContextV0 {
387            referencing_file: "src/App.module.scss".to_string(),
388        };
389
390        let canonical = resolver
391            .canonicalize(&context, "./Button.module.scss")
392            .map_err(|error| format!("expected canonical style URL: {error:?}"))?;
393        assert_eq!(canonical.url, "workspace:///src/Button.module.scss");
394
395        let loaded = resolver
396            .load(&canonical)
397            .map_err(|error| format!("expected loaded style source: {error:?}"))?;
398        assert_eq!(loaded.source, ".button { color: red; }");
399        Ok(())
400    }
401
402    #[test]
403    fn snapshot_resolver_forbids_network_references_during_canonicalization() -> Result<(), String>
404    {
405        let resolver = OmenaResolverStyleModuleSnapshotV0::new(["src/Button.module.scss"]);
406        let context = OmenaResolverReferenceContextV0 {
407            referencing_file: "src/App.module.scss".to_string(),
408        };
409
410        let error = match resolver.canonicalize(&context, "https://example.com/reset.css") {
411            Ok(canonical) => {
412                return Err(format!(
413                    "expected network reference to fail, got {canonical:?}"
414                ));
415            }
416            Err(error) => error,
417        };
418
419        assert_eq!(error.kind, OmenaResolverErrorKindV0::NetworkForbidden);
420        assert_eq!(error.kind_name, "networkForbidden");
421        Ok(())
422    }
423
424    #[test]
425    fn snapshot_resolver_reports_missing_snapshot_sources() -> Result<(), String> {
426        let resolver = OmenaResolverStyleModuleSnapshotV0::new(["src/Button.module.scss"]);
427        let context = OmenaResolverReferenceContextV0 {
428            referencing_file: "src/App.module.scss".to_string(),
429        };
430
431        let canonical = resolver
432            .canonicalize(&context, "./Button.module.scss")
433            .map_err(|error| format!("expected canonical style URL: {error:?}"))?;
434        let error = match resolver.load(&canonical) {
435            Ok(source) => return Err(format!("expected missing snapshot source, got {source:?}")),
436            Err(error) => error,
437        };
438
439        assert_eq!(error.kind, OmenaResolverErrorKindV0::NotFound);
440        Ok(())
441    }
442
443    #[test]
444    fn boundary_state_matrix_preserves_m7_external_states_and_top_semantics() {
445        let canonical = OmenaResolverCanonicalUrlV0::workspace_style_path("src/tokens.scss");
446        let states = [
447            OmenaResolverBoundaryStateV0::resolved(canonical.clone()),
448            OmenaResolverBoundaryStateV0::partial("external boundary kept in ignored mode"),
449            OmenaResolverBoundaryStateV0::stale(canonical.clone(), "lockfile hash drift"),
450            OmenaResolverBoundaryStateV0::missing(Some(canonical), "expected SIF is missing"),
451            OmenaResolverBoundaryStateV0::unresolved("specifier did not resolve"),
452        ];
453
454        assert_eq!(states[0].state_name, "resolved");
455        assert_eq!(states[0].top_name, "topOpaque");
456        assert_eq!(states[1].state_name, "partial");
457        assert_eq!(states[1].top_name, "topAny");
458        assert_eq!(states[2].state_name, "stale");
459        assert_eq!(states[2].top_name, "topAny");
460        assert_eq!(states[3].state_name, "missing");
461        assert_eq!(states[3].top_name, "topAny");
462        assert_eq!(states[4].state_name, "unresolved");
463        assert_eq!(states[4].top_name, "topAny");
464    }
465
466    #[test]
467    fn boundary_state_maps_existing_external_ignored_error_to_partial() {
468        let error = OmenaResolverErrorV0::new(
469            OmenaResolverErrorKindV0::ExternalIgnored,
470            "sass:map remains external in compatibility mode",
471        );
472
473        let state = omena_resolver_boundary_state_from_error_v0(&error);
474
475        assert_eq!(state.state, OmenaResolverBoundaryStateKindV0::Partial);
476        assert_eq!(state.top, OmenaResolverBoundaryTopV0::TopAny);
477        assert_eq!(
478            state.reason,
479            "sass:map remains external in compatibility mode"
480        );
481    }
482
483    #[test]
484    fn snapshot_resolver_preserves_tsconfig_path_mapping_resolution() -> Result<(), String> {
485        let resolver = OmenaResolverStyleModuleSnapshotV0::new([
486            "/fake/workspace/src/styles/Button.module.scss",
487        ])
488        .with_tsconfig_path_mappings(vec![OmenaResolverTsconfigPathMappingV0 {
489            base_path: "/fake/workspace".to_string(),
490            pattern: "@styles/*".to_string(),
491            target_patterns: vec!["src/styles/*".to_string()],
492        }]);
493        let context = OmenaResolverReferenceContextV0 {
494            referencing_file: "/fake/workspace/src/App.module.scss".to_string(),
495        };
496
497        let canonical = resolver
498            .canonicalize(&context, "@styles/Button")
499            .map_err(|error| format!("expected canonical style URL: {error:?}"))?;
500
501        assert_eq!(
502            canonical.as_workspace_style_path(),
503            Some("/fake/workspace/src/styles/Button.module.scss")
504        );
505        Ok(())
506    }
507
508    #[test]
509    fn snapshot_resolver_preserves_bundler_path_mapping_precedence() -> Result<(), String> {
510        let resolver = OmenaResolverStyleModuleSnapshotV0::new([
511            "/fake/workspace/src/bundler/Button.module.scss",
512            "/fake/workspace/src/tsconfig/Button.module.scss",
513        ])
514        .with_bundler_path_mappings(vec![OmenaResolverBundlerPathAliasMappingV0 {
515            pattern: "@styles".to_string(),
516            target_path: "/fake/workspace/src/bundler".to_string(),
517        }])
518        .with_tsconfig_path_mappings(vec![OmenaResolverTsconfigPathMappingV0 {
519            base_path: "/fake/workspace".to_string(),
520            pattern: "@styles/*".to_string(),
521            target_patterns: vec!["src/tsconfig/*".to_string()],
522        }]);
523        let context = OmenaResolverReferenceContextV0 {
524            referencing_file: "/fake/workspace/src/App.module.scss".to_string(),
525        };
526
527        let canonical = resolver
528            .canonicalize(&context, "@styles/Button")
529            .map_err(|error| format!("expected canonical style URL: {error:?}"))?;
530
531        assert_eq!(
532            canonical.as_workspace_style_path(),
533            Some("/fake/workspace/src/bundler/Button.module.scss")
534        );
535        Ok(())
536    }
537
538    #[test]
539    fn snapshot_resolver_preserves_package_manifest_resolution() -> Result<(), String> {
540        let resolver = OmenaResolverStyleModuleSnapshotV0::new([
541            "/fake/workspace/node_modules/@design/tokens/dist/theme.css",
542        ])
543        .with_package_manifests(vec![OmenaResolverStylePackageManifestV0 {
544            package_json_path: "/fake/workspace/node_modules/@design/tokens/package.json"
545                .to_string(),
546            package_json_source: r#"{"exports":{"./theme":{"style":"./dist/theme.css"}}}"#
547                .to_string(),
548        }]);
549        let context = OmenaResolverReferenceContextV0 {
550            referencing_file: "/fake/workspace/src/App.module.scss".to_string(),
551        };
552
553        let canonical = resolver
554            .canonicalize(&context, "@design/tokens/theme")
555            .map_err(|error| format!("expected canonical style URL: {error:?}"))?;
556
557        assert_eq!(
558            canonical.as_workspace_style_path(),
559            Some("/fake/workspace/node_modules/@design/tokens/dist/theme.css")
560        );
561        Ok(())
562    }
563}