Skip to main content

pliego_css/
lib.rs

1//! Public facade for `PliegoCSS`.
2//!
3//! The public API validates utilities during Rust compilation and exposes their emitted class
4//! identity to Rust renderers.
5//!
6//! The supported candidate surface is [`pc!`], [`pcx!`], [`Style`], and [`StyleId`]. Internal
7//! compiler crates remain an exact-version implementation graph rather than application APIs.
8//!
9//! ```
10//! use pliego_css::{Style, pc, pcx};
11//!
12//! const CARD: Style = pc!("flex gap-4 rounded-lg");
13//!
14//! let active = true;
15//! let selected = pcx!(
16//!     "block",
17//!     if active { "opacity-100" } else { "opacity-50" },
18//! );
19//!
20//! assert!(!CARD.is_empty());
21//! assert!(selected.class_name().starts_with("pc_"));
22//! assert_eq!(Style::EMPTY.class_name(), "");
23//! ```
24
25#![forbid(unsafe_code)]
26
27extern crate self as pliego_css;
28
29use core::fmt;
30
31/// Implementation exports used by the hygienic facade macros.
32#[doc(hidden)]
33pub mod __private {
34    pub use pliego_css_macros::{pc_id, pcx_id};
35
36    /// Constructs a style from output produced by the lockstep procedural macros.
37    #[must_use]
38    pub const fn style_from_compiled_id(id: u128) -> super::Style {
39        super::Style {
40            id: super::StyleId(pliego_css_ir::StyleId::new(id)),
41        }
42    }
43}
44
45/// Validates one utility string at compile time and returns its static [`Style`].
46///
47/// The declarative wrapper keeps `$crate` hygiene when the Cargo dependency is renamed; semantic
48/// parsing and identity derivation are delegated to the exact-version procedural macro crate.
49#[macro_export]
50macro_rules! pc {
51    ($($tokens:tt)*) => {
52        $crate::__private::style_from_compiled_id($crate::__private::pc_id!($($tokens)*))
53    };
54}
55
56/// Compiles every visible combination of conditional utility strings and selects one [`Style`].
57///
58/// Selector expressions are evaluated once in source order by the internal procedural expansion.
59#[macro_export]
60macro_rules! pcx {
61    ($($tokens:tt)*) => {
62        $crate::__private::style_from_compiled_id($crate::__private::pcx_id!($($tokens)*))
63    };
64}
65
66/// A compact identity for one normalized, theme-scoped style.
67///
68/// Application code receives IDs from [`Style::id`]. Integer construction is not part of the
69/// supported application API, and `Style` exposes no integer constructor.
70#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
71#[repr(transparent)]
72pub struct StyleId(pliego_css_ir::StyleId);
73
74impl StyleId {
75    /// Returns the complete deterministic 128-bit representation.
76    #[must_use]
77    pub const fn get(self) -> u128 {
78        self.0.get()
79    }
80
81    /// Returns whether this is the empty style sentinel.
82    #[must_use]
83    pub const fn is_unresolved(self) -> bool {
84        self.0.is_unresolved()
85    }
86
87    /// Encodes all identity bits as a lowercase CSS-safe class name.
88    ///
89    /// The empty sentinel encodes mechanically as `pc_0`; use [`Style::class_name`]
90    /// when rendering a [`Style`], because [`Style::EMPTY`] intentionally renders
91    /// no class at all.
92    #[must_use]
93    pub fn to_class_name(self) -> String {
94        self.0.to_class_name()
95    }
96}
97
98/// A compile-time validated style literal.
99///
100/// Construction is intentionally sealed; callers receive values from [`pc!`] or [`pcx!`].
101///
102/// ```compile_fail
103/// use pliego_css::Style;
104/// let _forged = Style { id: Style::EMPTY.id() };
105/// ```
106#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
107#[repr(transparent)]
108pub struct Style {
109    id: StyleId,
110}
111
112impl Style {
113    /// The empty style handle for conditional composition.
114    pub const EMPTY: Self = Self {
115        id: StyleId(pliego_css_ir::StyleId::UNRESOLVED),
116    };
117
118    /// Returns the stable identity derived from normalized semantics.
119    #[must_use]
120    pub const fn id(self) -> StyleId {
121        self.id
122    }
123
124    /// Returns the CSS class emitted for this style.
125    ///
126    /// Empty conditional styles return an empty string and can be interpolated directly.
127    #[must_use]
128    pub fn class_name(self) -> String {
129        if self.is_empty() {
130            String::new()
131        } else {
132            self.id.to_class_name()
133        }
134    }
135
136    /// Returns `true` when the handle contains no utilities.
137    #[must_use]
138    pub const fn is_empty(self) -> bool {
139        self.id.is_unresolved()
140    }
141}
142
143impl fmt::Display for Style {
144    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
145        if self.is_empty() {
146            Ok(())
147        } else {
148            formatter.write_str(&self.id.to_class_name())
149        }
150    }
151}
152
153impl From<Style> for String {
154    fn from(style: Style) -> Self {
155        style.class_name()
156    }
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162
163    const REUSABLE_CARD: Style = pc!("rounded-lg bg-surface p-6");
164
165    #[test]
166    fn macro_returns_validated_static_style() {
167        let style = pc!("flex items-center gap-4");
168        assert!(!style.id().is_unresolved());
169        assert!(style.class_name().starts_with("pc_"));
170        assert!(!style.is_empty());
171    }
172
173    #[test]
174    fn empty_style_is_explicit() {
175        assert!(Style::EMPTY.is_empty());
176        assert!(Style::EMPTY.id().is_unresolved());
177        assert_eq!(Style::EMPTY.id().to_class_name(), "pc_0");
178        assert!(Style::EMPTY.class_name().is_empty());
179        assert_eq!(Style::EMPTY.to_string(), "");
180    }
181
182    #[test]
183    fn style_formats_as_its_css_class() {
184        let style = pc!("flex gap-4");
185
186        assert_eq!(style.to_string(), style.class_name());
187        assert_eq!(String::from(style), style.class_name());
188    }
189
190    #[test]
191    fn runtime_handle_is_only_the_style_identity() {
192        assert_eq!(
193            core::mem::size_of::<StyleId>(),
194            core::mem::size_of::<u128>()
195        );
196        assert_eq!(
197            core::mem::size_of::<Style>(),
198            core::mem::size_of::<StyleId>()
199        );
200    }
201
202    #[test]
203    fn validated_styles_are_reusable_constants() {
204        assert_eq!(REUSABLE_CARD.id(), pc!("p-6 bg-surface rounded-lg").id());
205    }
206
207    #[test]
208    fn equivalent_utility_order_has_one_identity() {
209        assert_eq!(pc!("flex gap-4").id(), pc!("gap-4 flex").id());
210    }
211
212    #[test]
213    fn conditional_if_returns_a_complete_precompiled_style() {
214        let selected = true;
215        let style = pcx!(
216            "inline-flex rounded-md bg-surface",
217            if selected {
218                "bg-accent text-white"
219            } else {
220                "text-ink"
221            },
222        );
223
224        assert_eq!(
225            style.id(),
226            pc!("inline-flex rounded-md bg-accent text-white").id()
227        );
228    }
229
230    #[test]
231    fn conditional_match_compiles_every_literal_arm() {
232        enum Variant {
233            Primary,
234            Ghost,
235        }
236
237        let variant = Variant::Ghost;
238        let style = pcx!(
239            "rounded-md",
240            match variant {
241                Variant::Primary => "bg-accent text-white",
242                Variant::Ghost => "bg-transparent text-accent",
243            },
244        );
245
246        assert_eq!(
247            style.id(),
248            pc!("rounded-md bg-transparent text-accent").id()
249        );
250        let _ = Variant::Primary;
251    }
252
253    #[test]
254    fn independent_clauses_select_one_precompiled_cartesian_result() {
255        let active = false;
256        let compact = true;
257        let style = pcx!(
258            "inline-flex rounded-md",
259            if active { "opacity-100" } else { "opacity-50" },
260            if compact { "text-sm" } else { "text-lg" },
261        );
262
263        assert_eq!(
264            style.id(),
265            pc!("inline-flex rounded-md opacity-50 text-sm").id()
266        );
267    }
268
269    #[test]
270    fn each_independent_clause_expression_is_evaluated_once() {
271        use core::cell::Cell;
272
273        let evaluations = Cell::new(0_u8);
274        let condition = || {
275            evaluations.set(evaluations.get() + 1);
276            true
277        };
278        let style = pcx!(
279            "block",
280            if condition() {
281                "opacity-100"
282            } else {
283                "opacity-50"
284            },
285            if condition() { "text-sm" } else { "text-lg" },
286        );
287
288        assert_eq!(evaluations.get(), 2);
289        assert_eq!(style.id(), pc!("block opacity-100 text-sm").id());
290    }
291
292    #[test]
293    fn independent_if_and_match_preserve_rust_selection_semantics() {
294        enum Size {
295            Small,
296            Large,
297        }
298
299        let active = true;
300        let size = Size::Large;
301        let style = pcx!(
302            "inline-flex",
303            if active { "opacity-100" } else { "opacity-50" },
304            match size {
305                Size::Small => "text-sm",
306                Size::Large => "text-lg",
307            },
308        );
309
310        assert_eq!(style.id(), pc!("inline-flex opacity-100 text-lg").id());
311        let _ = Size::Small;
312    }
313}