Skip to main content

omena_interner/
lib.rs

1//! Workspace-level interned name identities for omena-css.
2//!
3//! `cstree` owns token storage inside green trees. This crate owns semantic
4//! string identity above the CST layer so hot-path equality can use typed
5//! interned IDs instead of repeated string comparison.
6//!
7//! ## Reuse cost: the `'db` lifetime (deliberate)
8//!
9//! The interned identities (`ClassName<'db>`, `CssIdent<'db>`, `PropertyName<'db>`,
10//! …) are `#[salsa::interned]` newtypes, so they carry the Salsa database
11//! lifetime `'db`. This is the ONE crate in the workspace that propagates `'db`
12//! onto consumers: any crate that holds these typed IDs inherits the lifetime.
13//! The coupling is deliberate — it buys O(1) typed-ID equality on the hot path —
14//! and is the documented price of reusing this R1 building block (see the role
15//! manifest: `omena-interner` is the `lifetime-coupled` reuse surface). Consumers
16//! that only need owned string identity stay above this layer: the V0 contracts
17//! carried across crate boundaries use owned data, not `'db`-bound IDs.
18
19use std::{error::Error, fmt};
20
21use omena_syntax::SymbolKind;
22use smol_str::SmolStr;
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
25pub enum NameKind {
26    ClassName,
27    CssIdent,
28    PropertyName,
29    SelectorKey,
30    CustomPropertyName,
31    KeyframesName,
32    MixinName,
33    FilePath,
34}
35
36impl NameKind {
37    pub const ALL: &'static [Self] = &[
38        Self::ClassName,
39        Self::CssIdent,
40        Self::PropertyName,
41        Self::SelectorKey,
42        Self::CustomPropertyName,
43        Self::KeyframesName,
44        Self::MixinName,
45        Self::FilePath,
46    ];
47
48    pub const fn canonical_symbol_kind(self) -> Option<SymbolKind> {
49        match self {
50            Self::ClassName => Some(SymbolKind::Class),
51            Self::CustomPropertyName => Some(SymbolKind::CustomProperty),
52            Self::KeyframesName => Some(SymbolKind::Keyframes),
53            Self::MixinName => Some(SymbolKind::Mixin),
54            Self::CssIdent | Self::PropertyName | Self::SelectorKey | Self::FilePath => None,
55        }
56    }
57}
58
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub enum InternError {
61    EmptyText { kind: NameKind },
62}
63
64impl fmt::Display for InternError {
65    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
66        match self {
67            Self::EmptyText { kind } => {
68                write!(formatter, "cannot intern empty text for {kind:?}")
69            }
70        }
71    }
72}
73
74impl Error for InternError {}
75
76#[salsa::interned(debug)]
77pub struct ClassName<'db> {
78    #[returns(ref)]
79    pub text: SmolStr,
80}
81
82#[salsa::interned(debug)]
83pub struct CssIdent<'db> {
84    #[returns(ref)]
85    pub text: SmolStr,
86}
87
88#[salsa::interned(debug)]
89pub struct PropertyName<'db> {
90    #[returns(ref)]
91    pub text: SmolStr,
92}
93
94#[salsa::interned(debug)]
95pub struct SelectorKey<'db> {
96    #[returns(ref)]
97    pub text: SmolStr,
98}
99
100#[salsa::interned(debug)]
101pub struct CustomPropertyName<'db> {
102    #[returns(ref)]
103    pub text: SmolStr,
104}
105
106#[salsa::interned(debug)]
107pub struct KeyframesName<'db> {
108    #[returns(ref)]
109    pub text: SmolStr,
110}
111
112#[salsa::interned(debug)]
113pub struct MixinName<'db> {
114    #[returns(ref)]
115    pub text: SmolStr,
116}
117
118#[salsa::interned(debug)]
119pub struct FilePath<'db> {
120    #[returns(ref)]
121    pub text: SmolStr,
122}
123
124pub fn intern_class_name<'db>(
125    db: &'db dyn salsa::Database,
126    text: impl Into<SmolStr>,
127) -> Result<ClassName<'db>, InternError> {
128    checked_text(NameKind::ClassName, text).map(|text| ClassName::new(db, text))
129}
130
131pub fn intern_css_ident<'db>(
132    db: &'db dyn salsa::Database,
133    text: impl Into<SmolStr>,
134) -> Result<CssIdent<'db>, InternError> {
135    checked_text(NameKind::CssIdent, text).map(|text| CssIdent::new(db, text))
136}
137
138pub fn intern_property_name<'db>(
139    db: &'db dyn salsa::Database,
140    text: impl Into<SmolStr>,
141) -> Result<PropertyName<'db>, InternError> {
142    checked_text(NameKind::PropertyName, text).map(|text| PropertyName::new(db, text))
143}
144
145pub fn intern_selector_key<'db>(
146    db: &'db dyn salsa::Database,
147    text: impl Into<SmolStr>,
148) -> Result<SelectorKey<'db>, InternError> {
149    checked_text(NameKind::SelectorKey, text).map(|text| SelectorKey::new(db, text))
150}
151
152pub fn intern_custom_property_name<'db>(
153    db: &'db dyn salsa::Database,
154    text: impl Into<SmolStr>,
155) -> Result<CustomPropertyName<'db>, InternError> {
156    checked_text(NameKind::CustomPropertyName, text).map(|text| CustomPropertyName::new(db, text))
157}
158
159pub fn intern_keyframes_name<'db>(
160    db: &'db dyn salsa::Database,
161    text: impl Into<SmolStr>,
162) -> Result<KeyframesName<'db>, InternError> {
163    checked_text(NameKind::KeyframesName, text).map(|text| KeyframesName::new(db, text))
164}
165
166pub fn intern_mixin_name<'db>(
167    db: &'db dyn salsa::Database,
168    text: impl Into<SmolStr>,
169) -> Result<MixinName<'db>, InternError> {
170    checked_text(NameKind::MixinName, text).map(|text| MixinName::new(db, text))
171}
172
173pub fn intern_file_path<'db>(
174    db: &'db dyn salsa::Database,
175    text: impl Into<SmolStr>,
176) -> Result<FilePath<'db>, InternError> {
177    checked_text(NameKind::FilePath, text).map(|text| FilePath::new(db, text))
178}
179
180fn checked_text(kind: NameKind, text: impl Into<SmolStr>) -> Result<SmolStr, InternError> {
181    let text = text.into();
182    if text.is_empty() {
183        Err(InternError::EmptyText { kind })
184    } else {
185        Ok(text)
186    }
187}
188
189#[derive(Debug, Clone, PartialEq, Eq)]
190pub struct OmenaInternerBoundarySummaryV0 {
191    pub schema_version: &'static str,
192    pub product: &'static str,
193    pub phase: &'static str,
194    pub name_kind_count: usize,
195    pub salsa_interned_type_count: usize,
196    pub validated_helper_count: usize,
197    pub symbol_mapped_name_kind_count: usize,
198    pub ready_surfaces: Vec<&'static str>,
199    pub next_surfaces: Vec<&'static str>,
200}
201
202pub fn summarize_omena_interner_boundary() -> OmenaInternerBoundarySummaryV0 {
203    OmenaInternerBoundarySummaryV0 {
204        schema_version: "0",
205        product: "omena-interner.boundary",
206        phase: "h1-beta-name-identity-substrate",
207        name_kind_count: NameKind::ALL.len(),
208        salsa_interned_type_count: 8,
209        validated_helper_count: 8,
210        symbol_mapped_name_kind_count: NameKind::ALL
211            .iter()
212            .filter(|kind| kind.canonical_symbol_kind().is_some())
213            .count(),
214        ready_surfaces: vec![
215            "typedSalsaInternedNames",
216            "validatedNameHelpers",
217            "syntaxSymbolKindMapping",
218            "workspaceFilePathIdentity",
219            "parserSemanticNameConsumption",
220            "semanticSoaNameTables",
221        ],
222        next_surfaces: Vec::new(),
223    }
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229
230    #[test]
231    fn declares_eight_workspace_name_kinds() {
232        assert_eq!(NameKind::ALL.len(), 8);
233        assert_eq!(
234            NameKind::CustomPropertyName.canonical_symbol_kind(),
235            Some(SymbolKind::CustomProperty),
236        );
237        assert_eq!(NameKind::FilePath.canonical_symbol_kind(), None);
238    }
239
240    #[test]
241    fn interns_equal_text_to_equal_typed_ids() {
242        let db = salsa::DatabaseImpl::default();
243
244        let first = intern_class_name(&db, "button");
245        let second = intern_class_name(&db, SmolStr::new("button"));
246
247        assert!(matches!((&first, &second), (Ok(_), Ok(_))));
248        if let (Ok(first), Ok(second)) = (first, second) {
249            assert_eq!(first, second);
250            assert_eq!(first.text(&db).as_str(), "button");
251        }
252    }
253
254    #[test]
255    fn keeps_name_kinds_type_separated() {
256        let db = salsa::DatabaseImpl::default();
257
258        let class_name = intern_class_name(&db, "primary");
259        let property_name = intern_property_name(&db, "primary");
260
261        assert!(matches!((&class_name, &property_name), (Ok(_), Ok(_))));
262        if let (Ok(class_name), Ok(property_name)) = (class_name, property_name) {
263            assert_eq!(class_name.text(&db), property_name.text(&db));
264        }
265    }
266
267    #[test]
268    fn rejects_empty_names_through_validated_helpers() {
269        let db = salsa::DatabaseImpl::default();
270
271        assert_eq!(
272            intern_mixin_name(&db, ""),
273            Err(InternError::EmptyText {
274                kind: NameKind::MixinName,
275            }),
276        );
277    }
278
279    #[test]
280    fn interns_all_declared_name_surfaces() {
281        let db = salsa::DatabaseImpl::default();
282
283        assert!(intern_css_ident(&db, "display").is_ok());
284        assert!(intern_selector_key(&db, ".button").is_ok());
285        assert!(intern_custom_property_name(&db, "--space").is_ok());
286        assert!(intern_keyframes_name(&db, "fade-in").is_ok());
287        assert!(intern_file_path(&db, "/workspace/Button.module.scss").is_ok());
288    }
289
290    #[test]
291    fn summarizes_phase_beta_name_identity_boundary() {
292        let summary = summarize_omena_interner_boundary();
293
294        assert_eq!(summary.product, "omena-interner.boundary");
295        assert_eq!(summary.phase, "h1-beta-name-identity-substrate");
296        assert_eq!(summary.name_kind_count, 8);
297        assert_eq!(summary.salsa_interned_type_count, 8);
298        assert_eq!(summary.validated_helper_count, 8);
299        assert_eq!(summary.symbol_mapped_name_kind_count, 4);
300        assert!(summary.ready_surfaces.contains(&"typedSalsaInternedNames"));
301        assert!(
302            summary
303                .ready_surfaces
304                .contains(&"parserSemanticNameConsumption")
305        );
306        assert!(summary.ready_surfaces.contains(&"semanticSoaNameTables"));
307        assert!(
308            !summary
309                .next_surfaces
310                .contains(&"parserSemanticNameConsumption")
311        );
312        assert!(!summary.next_surfaces.contains(&"semanticSoaNameTables"));
313    }
314}