Skip to main content

day_core/
element_id.rs

1// Copyright © The Daybrite Project
2// SPDX-License-Identifier: MPL-2.0
3
4//! Declared element identities. `id!` is the only declaration surface; the catalog is
5//! collected at link time, including declarations in helpers which have never mounted.
6use std::borrow::Cow;
7use std::collections::BTreeMap;
8
9/// An application-owned element identity, declared with [`crate::id!`].
10#[derive(Clone, Debug)]
11pub struct ElementId {
12    declaration: &'static Declaration,
13    text: Cow<'static, str>,
14}
15impl PartialEq for ElementId {
16    fn eq(&self, other: &Self) -> bool {
17        self.text == other.text
18    }
19}
20impl Eq for ElementId {}
21
22impl ElementId {
23    #[doc(hidden)]
24    pub fn from_declaration(declaration: &'static Declaration) -> Self {
25        Self {
26            declaration,
27            text: Cow::Borrowed(declaration.family),
28        }
29    }
30    #[doc(hidden)]
31    pub fn with_key(declaration: &'static Declaration, key: impl IntoElementKey) -> Self {
32        let mut text = String::from(declaration.family);
33        text.push(':');
34        for byte in key.into_element_key().bytes() {
35            if byte.is_ascii_alphanumeric() || b"-._~".contains(&byte) {
36                text.push(char::from(byte));
37            } else {
38                const HEX: &[u8] = b"0123456789ABCDEF";
39                text.push('%');
40                text.push(char::from(HEX[(byte >> 4) as usize]));
41                text.push(char::from(HEX[(byte & 15) as usize]));
42            }
43        }
44        Self {
45            declaration,
46            text: Cow::Owned(text),
47        }
48    }
49    pub fn as_str(&self) -> &str {
50        &self.text
51    }
52    pub fn family(&self) -> &'static str {
53        self.declaration.family
54    }
55    pub fn is_keyed(&self) -> bool {
56        self.declaration.keyed
57    }
58}
59
60/// Explicit, stable canonical text for an instance key. No blanket Display conversion.
61/// Integer 42 and text "42" intentionally denote the same key. Composite keys must
62/// define an unambiguous app-owned encoding before the framework percent-escapes it.
63pub trait IntoElementKey {
64    fn into_element_key(self) -> String;
65}
66impl IntoElementKey for String {
67    fn into_element_key(self) -> String {
68        self
69    }
70}
71impl IntoElementKey for &str {
72    fn into_element_key(self) -> String {
73        self.to_owned()
74    }
75}
76impl IntoElementKey for &String {
77    fn into_element_key(self) -> String {
78        self.clone()
79    }
80}
81macro_rules! integer_keys {
82    ($($ty:ty),*) => { $(impl IntoElementKey for $ty {
83        fn into_element_key(self) -> String { self.to_string() }
84    })* };
85}
86integer_keys!(
87    u8, u16, u32, u64, u128, usize, i8, i16, i32, i64, i128, isize
88);
89
90#[doc(hidden)]
91#[derive(Debug, PartialEq, Eq)]
92pub struct Declaration {
93    pub family: &'static str,
94    pub keyed: bool,
95    pub package: &'static str,
96    pub version: &'static str,
97    pub file: &'static str,
98    pub line: u32,
99}
100/// Target-specific, normalized declaration. Provenance is omitted in release builds.
101#[derive(Clone, Debug, PartialEq, Eq)]
102pub struct RegisteredId {
103    pub family: String,
104    pub keyed: bool,
105    pub package: String,
106    pub version: String,
107    pub locations: Vec<String>,
108}
109#[cfg(not(target_arch = "wasm32"))]
110#[linkme::distributed_slice]
111#[doc(hidden)]
112pub static DECLARATIONS: [Declaration];
113
114/// Return the linked app's vocabulary, independent of visited pages. Reject conflicting
115/// shapes and owners; repeated declarations within one package are permitted.
116pub fn registry() -> Result<&'static [RegisteredId], String> {
117    #[cfg(not(target_arch = "wasm32"))]
118    {
119        static CATALOG: std::sync::OnceLock<Result<Vec<RegisteredId>, String>> =
120            std::sync::OnceLock::new();
121        CATALOG
122            .get_or_init(|| {
123                normalize(
124                    DECLARATIONS
125                        .iter()
126                        .map(|d| RegisteredId {
127                            family: d.family.into(),
128                            keyed: d.keyed,
129                            package: d.package.into(),
130                            version: d.version.into(),
131                            locations: if d.file.is_empty() {
132                                vec![]
133                            } else {
134                                vec![format!("{}:{}", d.file, d.line)]
135                            },
136                        })
137                        .collect(),
138                )
139            })
140            .as_ref()
141            .map(Vec::as_slice)
142            .map_err(Clone::clone)
143    }
144    #[cfg(target_arch = "wasm32")]
145    WEB_REGISTRY
146        .get()
147        .ok_or("missing day_ids custom section handoff")?
148        .as_ref()
149        .map(Vec::as_slice)
150        .map_err(Clone::clone)
151}
152/// The exact family portion of a canonical locator; used only for mounted diagnostics.
153pub fn family_of(id: &str) -> &str {
154    let mut chars = id.char_indices().peekable();
155    while let Some((i, c)) = chars.next() {
156        if c == ':' {
157            if chars.peek().is_some_and(|(_, c)| *c == ':') {
158                chars.next();
159            } else {
160                return &id[..i];
161            }
162        }
163    }
164    id
165}
166
167fn normalize(records: Vec<RegisteredId>) -> Result<Vec<RegisteredId>, String> {
168    let mut catalog: BTreeMap<String, RegisteredId> = BTreeMap::new();
169    for record in records {
170        if let Some(prior) = catalog.get_mut(&record.family) {
171            if (prior.keyed, &prior.package, &prior.version)
172                != (record.keyed, &record.package, &record.version)
173            {
174                let owner = |r: &RegisteredId| {
175                    let shape = if r.keyed { "keyed" } else { "static" };
176                    match r.locations.first() {
177                        Some(at) => format!("{shape} in {} {} at {at}", r.package, r.version),
178                        None => format!("{shape} in {} {}", r.package, r.version),
179                    }
180                };
181                return Err(format!(
182                    "conflicting element ID declaration: {} ({}; {})",
183                    record.family,
184                    owner(prior),
185                    owner(&record)
186                ));
187            }
188            prior.locations.extend(record.locations);
189            prior.locations.sort();
190            prior.locations.dedup();
191        } else {
192            catalog.insert(record.family.clone(), record);
193        }
194    }
195    Ok(catalog.into_values().collect())
196}
197
198#[doc(hidden)]
199pub const fn validate_name(name: &str) {
200    let bytes = name.as_bytes();
201    assert!(!bytes.is_empty(), "element ID must not be empty");
202    let mut start = 0;
203    let mut i = 0;
204    while i <= bytes.len() {
205        if i == bytes.len() || bytes[i] == b':' {
206            assert!(i > start, "empty element ID namespace segment");
207            assert!(
208                bytes[start] >= b'a' && bytes[start] <= b'z',
209                "element ID must start with a lowercase ASCII letter"
210            );
211            assert!(
212                bytes[i - 1] != b'_',
213                "element ID must not end with underscore"
214            );
215            // Rust ident fragments accept keywords, including raw identifiers. Keep the
216            // protocol vocabulary independent of the edition used by the calling crate.
217            let keywords = [
218                "as",
219                "async",
220                "await",
221                "break",
222                "const",
223                "continue",
224                "crate",
225                "dyn",
226                "else",
227                "enum",
228                "extern",
229                "false",
230                "fn",
231                "for",
232                "if",
233                "impl",
234                "in",
235                "let",
236                "loop",
237                "match",
238                "mod",
239                "move",
240                "mut",
241                "pub",
242                "ref",
243                "return",
244                "self",
245                "static",
246                "struct",
247                "super",
248                "trait",
249                "true",
250                "type",
251                "unsafe",
252                "use",
253                "where",
254                "while",
255                "abstract",
256                "become",
257                "box",
258                "do",
259                "final",
260                "gen",
261                "macro",
262                "override",
263                "priv",
264                "try",
265                "typeof",
266                "unsized",
267                "virtual",
268                "yield",
269                "union",
270                "macro_rules",
271            ];
272            let mut k = 0;
273            while k < keywords.len() {
274                let keyword = keywords[k].as_bytes();
275                let mut equal = keyword.len() == i - start;
276                let mut j = 0;
277                while equal && j < keyword.len() {
278                    equal = bytes[start + j] == keyword[j];
279                    j += 1;
280                }
281                assert!(!equal, "Rust keyword is not an element ID");
282                k += 1;
283            }
284            if i == bytes.len() {
285                break;
286            }
287            assert!(
288                i + 1 < bytes.len() && bytes[i + 1] == b':',
289                "invalid namespace separator"
290            );
291            i += 2;
292            start = i;
293        } else {
294            let b = bytes[i];
295            assert!(
296                (b >= b'a' && b <= b'z') || (b >= b'0' && b <= b'9') || b == b'_',
297                "element IDs use lowercase ASCII snake_case, not raw identifiers"
298            );
299            assert!(
300                b != b'_' || i == start || bytes[i - 1] != b'_',
301                "double underscore in element ID"
302            );
303            i += 1;
304        }
305    }
306}
307
308/// Declare an ID at its piece, including a link-time catalog entry.
309///
310/// ```
311/// let save = day_core::id!(account::save);
312/// let row = day_core::id!(query_row, 42_u64);
313/// assert_eq!(save.as_str(), "account::save");
314/// assert_eq!(row.as_str(), "query_row:42");
315/// ```
316///
317/// Names are declarations using identifier tokens, with no string or case-conversion path.
318///
319/// ```compile_fail
320/// let _ = day_core::id!("save");
321/// ```
322/// ```compile_fail
323/// let _ = day_core::id!(Save);
324/// ```
325/// ```compile_fail
326/// let _ = day_core::id!(r#save);
327/// ```
328/// ```compile_fail
329/// let _ = day_core::id!(account__save);
330/// ```
331/// ```compile_fail
332/// let _ = day_core::id!(type);
333/// ```
334#[macro_export]
335macro_rules! id {
336    ($first:ident $(:: $rest:ident)* $(,)?) => {{
337        $crate::__declare_element_id!(false, "s", $first $(:: $rest)*);
338        $crate::ElementId::from_declaration(&__DAY_ID)
339    }};
340    ($first:ident $(:: $rest:ident)*, $key:expr $(,)?) => {{
341        $crate::__declare_element_id!(true, "k", $first $(:: $rest)*);
342        $crate::ElementId::with_key(&__DAY_ID, $key)
343    }};
344}
345#[doc(hidden)]
346#[macro_export]
347macro_rules! __declare_element_id {
348    ($keyed:expr, $flag:literal, $first:ident $(:: $rest:ident)*) => {
349        const __NAME: &str = concat!(stringify!($first), $("::", stringify!($rest)),*);
350        const _: () = $crate::element_id::validate_name(__NAME);
351        #[cfg(not(target_arch = "wasm32"))]
352        #[$crate::linkme::distributed_slice($crate::element_id::DECLARATIONS)]
353        #[linkme(crate = $crate::linkme)]
354        static __DAY_ID: $crate::element_id::Declaration = $crate::element_id::Declaration {
355            family: __NAME, keyed: $keyed, package: env!("CARGO_PKG_NAME"), version: env!("CARGO_PKG_VERSION"),
356            file: if cfg!(debug_assertions) { file!() } else { "" },
357            line: if cfg!(debug_assertions) { line!() } else { 0 },
358        };
359        #[cfg(target_arch = "wasm32")]
360        static __DAY_ID: $crate::element_id::Declaration = $crate::element_id::Declaration {
361            family: __NAME, keyed: $keyed, package: env!("CARGO_PKG_NAME"), version: env!("CARGO_PKG_VERSION"), file: "", line: 0,
362        };
363        #[cfg(target_arch = "wasm32")]
364        const __PAYLOAD: &str = concat!("D1\0", $flag, "\0", stringify!($first), $("::", stringify!($rest),)* "\0", env!("CARGO_PKG_NAME"), "\0", env!("CARGO_PKG_VERSION"));
365        #[cfg(target_arch = "wasm32")]
366        #[$crate::__retain_element_id]
367        #[used]
368        #[unsafe(link_section = "day_ids")]
369        static __DAY_ID_BYTES: [u8; __PAYLOAD.len()+4] = $crate::element_id::record_bytes(__PAYLOAD);
370    };
371}
372
373#[doc(hidden)]
374pub const fn record_bytes<const N: usize>(payload: &str) -> [u8; N] {
375    let mut out = [0; N];
376    let length = (payload.len() as u32).to_le_bytes();
377    let mut i = 0;
378    while i < 4 {
379        out[i] = length[i];
380        i += 1;
381    }
382    i = 0;
383    while i < payload.len() {
384        out[i + 4] = payload.as_bytes()[i];
385        i += 1;
386    }
387    out
388}
389#[cfg(target_arch = "wasm32")]
390#[crate::__retain_element_id]
391#[used]
392#[unsafe(link_section = "day_ids")]
393static SENTINEL: [u8; 6] = record_bytes("D1");
394#[cfg(target_arch = "wasm32")]
395static WEB_REGISTRY: std::sync::OnceLock<Result<Vec<RegisteredId>, String>> =
396    std::sync::OnceLock::new();
397/// Install all concatenated custom sections once, before app startup. Invalid metadata
398/// remains an error at the protocol boundary; it never becomes an empty catalog.
399#[cfg(target_arch = "wasm32")]
400pub fn install_web_registry(bytes: &[u8]) -> Result<(), String> {
401    WEB_REGISTRY
402        .set(decode_records(bytes))
403        .map_err(|_| "element registry already installed".to_owned())
404}
405#[cfg(any(test, target_arch = "wasm32"))]
406fn decode_records(mut bytes: &[u8]) -> Result<Vec<RegisteredId>, String> {
407    let mut records = Vec::new();
408    let mut sentinel = false;
409    while !bytes.is_empty() {
410        let length = bytes.get(..4).ok_or("truncated day_ids length")?;
411        let length = u32::from_le_bytes(length.try_into().unwrap()) as usize;
412        bytes = &bytes[4..];
413        let payload = bytes.get(..length).ok_or("truncated day_ids record")?;
414        bytes = &bytes[length..];
415        let text = std::str::from_utf8(payload).map_err(|_| "non UTF-8 day_ids record")?;
416        if text == "D1" {
417            sentinel = true;
418            continue;
419        }
420        let fields: Vec<_> = text.split('\0').collect();
421        if fields.len() != 5
422            || fields[0] != "D1"
423            || !matches!(fields[1], "s" | "k")
424            || fields[2].is_empty()
425        {
426            return Err("invalid day_ids schema/record".into());
427        }
428        records.push(RegisteredId {
429            family: fields[2].into(),
430            keyed: fields[1] == "k",
431            package: fields[3].into(),
432            version: fields[4].into(),
433            locations: vec![],
434        });
435    }
436    if !sentinel {
437        return Err("missing day_ids registry sentinel".into());
438    }
439    normalize(records)
440}
441
442#[cfg(test)]
443mod tests {
444    use super::*;
445    #[test]
446    fn exact_names_and_injective_keys() {
447        assert_eq!(crate::id!(identity), crate::id!(identity));
448        assert_eq!(crate::id!(account::save).as_str(), "account::save");
449        assert_eq!(
450            crate::id!(query_row, ":% café").as_str(),
451            "query_row:%3A%25%20caf%C3%A9"
452        );
453        assert_eq!(
454            crate::id!(query_row, 42).as_str(),
455            crate::id!(query_row, "42").as_str()
456        );
457        let calls = std::cell::Cell::new(0);
458        let _ = crate::id!(once, {
459            calls.set(calls.get() + 1);
460            3
461        });
462        assert_eq!(calls.get(), 1);
463    }
464    #[allow(dead_code)]
465    fn unused<T>(_: Option<T>) {
466        let _ = crate::id!(never_mounted);
467    }
468    #[test]
469    fn registry_includes_unused_generic() {
470        assert!(
471            registry()
472                .unwrap()
473                .iter()
474                .any(|d| d.family == "never_mounted")
475        );
476    }
477    #[test]
478    fn framed_metadata_and_fail_closed() {
479        let mut data = record_bytes::<6>("D1").to_vec();
480        let payload = concat!("D1\0k\0row\0app\0", "1");
481        data.extend_from_slice(&(payload.len() as u32).to_le_bytes());
482        data.extend_from_slice(payload.as_bytes());
483        assert_eq!(decode_records(&data).unwrap()[0].family, "row");
484        assert!(decode_records(&data[6..]).is_err());
485        assert!(decode_records(&data[..data.len() - 1]).is_err());
486    }
487    #[test]
488    fn normalization_rejects_owner_and_shape_conflicts() {
489        let record = RegisteredId {
490            family: "save".into(),
491            keyed: false,
492            package: "fixture".into(),
493            version: "1".into(),
494            locations: vec!["fixture.rs:1".into()],
495        };
496        let mut duplicate = record.clone();
497        duplicate.locations = vec!["fixture.rs:2".into()];
498        let normalized = normalize(vec![record.clone(), duplicate.clone()]).unwrap();
499        assert_eq!(normalized.len(), 1);
500        assert_eq!(normalized[0].locations.len(), 2);
501        duplicate.keyed = true;
502        assert!(normalize(vec![record.clone(), duplicate.clone()]).is_err());
503        duplicate.keyed = false;
504        duplicate.package = "other_fixture".into();
505        // The message names both owners, so a collision between two crates is traceable.
506        assert_eq!(
507            normalize(vec![record, duplicate]).unwrap_err(),
508            "conflicting element ID declaration: save (static in fixture 1 at fixture.rs:1; \
509             static in other_fixture 1 at fixture.rs:2)"
510        );
511    }
512}