mezzaluna_conversion_methods/
lib.rs

1#![warn(clippy::all, clippy::pedantic, clippy::undocumented_unsafe_blocks)]
2#![allow(
3    clippy::let_underscore_untyped,
4    reason = "https://github.com/rust-lang/rust-clippy/pull/10442#issuecomment-1516570154"
5)]
6#![allow(
7    clippy::question_mark,
8    reason = "https://github.com/rust-lang/rust-clippy/issues/8281"
9)]
10#![allow(clippy::manual_let_else, reason = "manual_let_else was very buggy on release")]
11#![allow(clippy::missing_errors_doc, reason = "A lot of existing code fails this lint")]
12#![allow(
13    clippy::unnecessary_lazy_evaluations,
14    reason = "https://github.com/rust-lang/rust-clippy/issues/8109"
15)]
16#![cfg_attr(
17    test,
18    allow(clippy::non_ascii_literal, reason = "tests sometimes require UTF-8 string content")
19)]
20#![allow(unknown_lints)]
21#![warn(
22    missing_copy_implementations,
23    missing_debug_implementations,
24    missing_docs,
25    rust_2024_compatibility,
26    trivial_casts,
27    trivial_numeric_casts,
28    unused_qualifications,
29    variant_size_differences
30)]
31#![forbid(unsafe_code)]
32// Enable feature callouts in generated documentation:
33// https://doc.rust-lang.org/beta/unstable-book/language-features/doc-cfg.html
34//
35// This approach is borrowed from tokio.
36#![cfg_attr(docsrs, feature(doc_cfg))]
37#![cfg_attr(docsrs, feature(doc_alias))]
38
39//! Ruby implicit conversion vocabulary types.
40//!
41//! This crate provides a lookup table for Ruby object conversion methods and
42//! their metadata. It maps method names to their C string equivalents and
43//! categorizes them as either an implicit conversion or coercion. This is used
44//! when booting an Artichoke interpreter and for implementing native Ruby
45//! object conversion routines.
46//!
47//! # Examples
48//!
49//! ```
50//! use intaglio::bytes::SymbolTable;
51//! use mezzaluna_conversion_methods::{ConvMethods, InitError};
52//!
53//! # fn example() -> Result<(), InitError> {
54//! let mut symbols = SymbolTable::new();
55//! let methods = ConvMethods::new();
56//! let table = methods.get_or_init(&mut symbols)?;
57//! assert_eq!(table.len(), 12);
58//!
59//! let method = methods.find_method(&mut symbols, "to_int")?;
60//! assert!(method.is_some());
61//! # Ok(())
62//! # }
63
64use core::error;
65use core::ffi::CStr;
66use core::fmt;
67use core::hash::BuildHasher;
68use std::sync::OnceLock;
69
70use intaglio::SymbolOverflowError;
71use intaglio::bytes::SymbolTable;
72
73// Ensure code blocks in `README.md` compile
74#[cfg(doctest)]
75#[doc = include_str!("../README.md")]
76mod readme {}
77
78/// Whether the conversion is implicit, like `#to_int`, or a coercion, like
79/// `#to_i`.
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub enum ConversionType {
82    /// The conversion is implicit, like `#to_int`.
83    Implicit,
84    /// The conversion is a coercion, like `#to_i`.
85    Coercion,
86}
87
88impl ConversionType {
89    /// Returns whether the conversion is implicit.
90    #[inline]
91    #[must_use]
92    pub const fn is_implicit(&self) -> bool {
93        matches!(self, Self::Implicit)
94    }
95
96    /// Returns whether the conversion is a coercion.
97    #[inline]
98    #[must_use]
99    pub const fn is_coercion(&self) -> bool {
100        matches!(self, Self::Coercion)
101    }
102}
103
104/// Defines the supported Ruby object conversion methods and their metadata.
105///
106/// This constant provides a lookup table for methods used to convert Ruby
107/// objects to specific types. It maps method names to their C string
108/// equivalents and categorizes them as either an implicit conversion or
109/// coercion.  This is used to facilitate handling of type conversions in Ruby,
110/// ensuring consistent behavior for operations like implicit coercion or
111/// explicit type casting.
112///
113/// Corresponds to the conversion methods defined in Ruby's `conv_method_tbl` in
114/// `object.c`.
115///
116/// Reference: <https://github.com/ruby/ruby/blob/v3_4_1/object.c#L3095-L3114>
117#[rustfmt::skip]
118pub const CONVERSION_METHODS: [(&str, &CStr, ConversionType); 12] = [
119    ("to_int",  c"to_int",  ConversionType::Implicit),
120    ("to_ary",  c"to_ary",  ConversionType::Implicit),
121    ("to_str",  c"to_str",  ConversionType::Implicit),
122    ("to_sym",  c"to_sym",  ConversionType::Implicit),
123    ("to_hash", c"to_hash", ConversionType::Implicit),
124    ("to_proc", c"to_proc", ConversionType::Implicit),
125    ("to_io",   c"to_io",   ConversionType::Implicit),
126    ("to_a",    c"to_a",    ConversionType::Coercion),
127    ("to_s",    c"to_s",    ConversionType::Coercion),
128    ("to_i",    c"to_i",    ConversionType::Coercion),
129    ("to_f",    c"to_f",    ConversionType::Coercion),
130    ("to_r",    c"to_r",    ConversionType::Coercion),
131];
132
133/// Error type for conversion method table initialization failures.
134///
135/// See [`ConvMethods::get_or_init`] for more information.
136#[derive(Default, Debug)]
137#[allow(missing_copy_implementations)]
138pub struct InitError {
139    cause: Option<SymbolOverflowError>,
140}
141
142impl fmt::Display for InitError {
143    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
144        f.write_str(self.message())
145    }
146}
147
148impl error::Error for InitError {
149    fn source(&self) -> Option<&(dyn error::Error + 'static)> {
150        if let Some(ref cause) = self.cause {
151            Some(cause)
152        } else {
153            None
154        }
155    }
156}
157
158impl From<SymbolOverflowError> for InitError {
159    fn from(err: SymbolOverflowError) -> Self {
160        Self { cause: Some(err) }
161    }
162}
163
164impl InitError {
165    /// Create a new `InitError`.
166    ///
167    /// # Examples
168    ///
169    /// ```
170    /// use mezzaluna_conversion_methods::InitError;
171    ///
172    /// const ERR: InitError = InitError::new();
173    /// ```
174    #[must_use]
175    pub const fn new() -> Self {
176        Self { cause: None }
177    }
178
179    /// Returns a message describing the error.
180    ///
181    /// # Examples
182    ///
183    /// ```
184    /// use mezzaluna_conversion_methods::InitError;
185    ///
186    /// const ERR: InitError = InitError::new();
187    /// assert_eq!(ERR.message(), "conversion method table initialization failed");
188    /// ```
189    #[must_use]
190    pub const fn message(&self) -> &'static str {
191        "conversion method table initialization failed"
192    }
193}
194
195/// Represents a single Ruby conversion method, including its name, C string
196/// representation, unique identifier, and whether it is an implicit conversion.
197///
198/// This struct is used to encapsulate the attributes of conversion methods to
199/// enable efficient lookups and operations in the context of Ruby object
200/// conversions.
201#[derive(Debug, Clone, Copy, PartialEq, Eq)]
202pub struct ConvMethod {
203    /// The name of the Ruby method as a string slice.
204    method: &'static str,
205    /// The C string representation of the method name.
206    cstr: &'static CStr,
207    /// A unique identifier for the method, derived from the Ruby interpreter
208    /// symbol interner.
209    id: u32,
210    /// Whether the method performs an implicit conversion.
211    conversion_type: ConversionType,
212}
213
214impl ConvMethod {
215    /// Returns the name of the conversion method.
216    #[inline]
217    #[must_use]
218    pub fn name(&self) -> &str {
219        self.method
220    }
221
222    /// Returns the C string representation of the conversion method.
223    #[inline]
224    #[must_use]
225    pub fn cstr(&self) -> &CStr {
226        self.cstr
227    }
228
229    /// Returns the interned symbol id for the conversion method.
230    #[inline]
231    #[must_use]
232    pub fn symbol(&self) -> u32 {
233        self.id
234    }
235
236    /// Returns whether the conversion method is an implicit conversion.
237    #[inline]
238    #[must_use]
239    pub const fn is_implicit(&self) -> bool {
240        self.conversion_type.is_implicit()
241    }
242
243    /// Returns whether the conversion method is a coercion.
244    #[inline]
245    #[must_use]
246    pub const fn is_coercion(&self) -> bool {
247        self.conversion_type.is_coercion()
248    }
249}
250
251/// A table of Ruby conversion methods and their metadata.
252///
253/// This struct provides a lazily initiated lookup table for Ruby object
254/// conversion methods and their metadata. See [`CONVERSION_METHODS`] for the
255/// list of supported conversion methods and [`ConvMethod`] for the metadata
256/// associated with each method.
257#[derive(Debug, Default)]
258pub struct ConvMethods {
259    table: OnceLock<[ConvMethod; 12]>,
260}
261
262impl ConvMethods {
263    /// Create a new `ConvMethods`.
264    ///
265    /// # Examples
266    ///
267    /// ```
268    /// use mezzaluna_conversion_methods::ConvMethods;
269    /// let methods = ConvMethods::new();
270    /// ```
271    #[must_use]
272    pub const fn new() -> Self {
273        Self { table: OnceLock::new() }
274    }
275
276    /// Get the conversion methods table.
277    ///
278    /// This method returns a reference to the conversion methods table if it has
279    /// been initialized, or `None` if it has not been initialized.
280    ///
281    /// # Examples
282    ///
283    /// ```
284    /// use mezzaluna_conversion_methods::ConvMethods;
285    ///
286    /// let methods = ConvMethods::new();
287    /// assert!(methods.get().is_none());
288    /// ```
289    #[must_use]
290    pub fn get(&self) -> Option<&[ConvMethod; 12]> {
291        self.table.get()
292    }
293
294    /// Get the conversion methods table, initializing it if necessary.
295    ///
296    /// This method returns a reference to the conversion methods table, which
297    /// is lazily initialized on the first call. This method is idempotent and
298    /// will return the same reference on subsequent calls.
299    ///
300    /// # Errors
301    ///
302    /// If the table cannot be initialized due to an error interning the symbol,
303    /// an [`InitError`] is returned. Due to limitations of the Rust standard library,
304    /// this error is never returned and instead causes a panic.
305    ///
306    /// # Panics
307    ///
308    /// This method panics if the symbol table cannot be interned. This should be
309    /// a rare occurrence, as the symbol table is typically initialized during
310    /// interpreter setup.
311    ///
312    /// # Examples
313    ///
314    /// ```
315    /// use intaglio::bytes::SymbolTable;
316    /// use mezzaluna_conversion_methods::{ConvMethods, InitError};
317    ///
318    /// # fn example() -> Result<(), InitError> {
319    /// let mut symbols = SymbolTable::new();
320    /// let methods = ConvMethods::new();
321    /// let table = methods.get_or_init(&mut symbols)?;
322    /// assert_eq!(table.len(), 12);
323    /// assert!(methods.get().is_some());
324    /// # Ok(())
325    /// # }
326    /// ```
327    pub fn get_or_init<'a, S>(&'a self, symbols: &mut SymbolTable<S>) -> Result<&'a [ConvMethod; 12], InitError>
328    where
329        S: BuildHasher,
330    {
331        Ok(self.table.get_or_init(|| {
332            let mut metadata = [ConvMethod {
333                method: "",
334                cstr: c"",
335                id: u32::MAX,
336                conversion_type: ConversionType::Implicit,
337            }; CONVERSION_METHODS.len()];
338
339            for (cell, (method, cstr, conversion_type)) in metadata.iter_mut().zip(CONVERSION_METHODS) {
340                // NOTE: This relies on internals of how Artichoke stores the
341                // symbol with a trailing NUL byte. It is not great that we
342                // can't go through `<Artichoke as Intern>::intern_bytes_with_trailing_nul`,
343                // but this is necessary because we need mutable access to a
344                // different part of the state.
345                let bytes = cstr.to_bytes_with_nul();
346
347                // TODO: Ideally we wouldn't be unwrapping here and could use a
348                // fallible initializer.  `OnceLock` doesn't support fallible
349                // initializers yet. See `OnceLock::get_or_try_init`, tracked in
350                // https://github.com/rust-lang/rust/issues/109737.
351                let sym = symbols
352                    .intern(bytes)
353                    .expect("interpreter setup requires interning conversion methods");
354
355                *cell = ConvMethod {
356                    method,
357                    cstr,
358                    id: sym.into(),
359                    conversion_type,
360                };
361            }
362
363            metadata
364        }))
365    }
366
367    /// Find the conversion metadata for the given method name.
368    ///
369    /// This method searches the conversion methods table for the specified
370    /// method name and returns the corresponding conversion metadata if found.
371    ///
372    /// This method will initialize the conversion methods table if it has not
373    /// been initialized yet.
374    ///
375    /// # Errors
376    ///
377    /// If the conversion methods table cannot be initialized, an [`InitError`] is returned.
378    /// See [`ConvMethods::get_or_init`] for more information.
379    pub fn find_method<S>(&self, symbols: &mut SymbolTable<S>, method: &str) -> Result<Option<ConvMethod>, InitError>
380    where
381        S: BuildHasher,
382    {
383        let table = self.get_or_init(symbols)?;
384        let method = table.iter().find(|conv| conv.method == method).copied();
385        Ok(method)
386    }
387}
388
389#[cfg(test)]
390mod tests {
391    use std::{error::Error, ptr};
392
393    use intaglio::bytes::SymbolTable;
394
395    use super::*;
396
397    #[test]
398    fn test_conversion_type_is_implicit() {
399        let conversion = ConversionType::Implicit;
400        assert!(conversion.is_implicit());
401        assert!(!conversion.is_coercion());
402    }
403
404    #[test]
405    fn test_conversion_type_is_coercion() {
406        let conversion = ConversionType::Coercion;
407        assert!(conversion.is_coercion());
408        assert!(!conversion.is_implicit());
409    }
410
411    #[test]
412    fn test_conversion_type_equality() {
413        assert_eq!(ConversionType::Implicit, ConversionType::Implicit);
414        assert_eq!(ConversionType::Coercion, ConversionType::Coercion);
415        assert_ne!(ConversionType::Implicit, ConversionType::Coercion);
416        assert_ne!(ConversionType::Coercion, ConversionType::Implicit);
417    }
418
419    #[test]
420    fn test_conversion_type_debug() {
421        assert_eq!(format!("{:?}", ConversionType::Implicit), "Implicit");
422        assert_eq!(format!("{:?}", ConversionType::Coercion), "Coercion");
423    }
424
425    #[test]
426    fn test_error_default() {
427        let error = InitError::default();
428        assert!(error.cause.is_none());
429        assert!(error.source().is_none());
430    }
431
432    #[test]
433    fn test_error_from_symbol_overflow_error() {
434        let error = SymbolOverflowError::new();
435        let init_error = InitError::from(error);
436        assert!(init_error.cause.is_some());
437        assert!(init_error.source().is_some());
438    }
439
440    #[test]
441    fn test_error_display() {
442        let error = InitError::default();
443        assert_eq!(error.to_string(), "conversion method table initialization failed");
444    }
445
446    #[test]
447    fn test_conv_method_name() {
448        let cstr = c"to_int";
449        let method = ConvMethod {
450            method: "to_int",
451            cstr,
452            id: 1,
453            conversion_type: ConversionType::Implicit,
454        };
455
456        assert_eq!(method.name(), "to_int");
457    }
458
459    #[test]
460    fn test_conv_method_cstr() {
461        let cstr = c"to_str";
462        let method = ConvMethod {
463            method: "to_str",
464            cstr,
465            id: 2,
466            conversion_type: ConversionType::Implicit,
467        };
468
469        assert_eq!(method.cstr().to_str().unwrap(), "to_str");
470    }
471
472    #[test]
473    fn test_conv_method_symbol() {
474        let cstr = c"to_sym";
475        let method = ConvMethod {
476            method: "to_sym",
477            cstr,
478            id: 42,
479            conversion_type: ConversionType::Coercion,
480        };
481
482        assert_eq!(method.symbol(), 42);
483    }
484
485    #[test]
486    fn test_conv_method_is_implicit() {
487        let cstr = c"to_ary";
488        let method = ConvMethod {
489            method: "to_ary",
490            cstr,
491            id: 3,
492            conversion_type: ConversionType::Implicit,
493        };
494
495        assert!(method.is_implicit());
496        assert!(!method.is_coercion());
497    }
498
499    #[test]
500    fn test_conv_method_is_coercion() {
501        let cstr = c"to_i";
502        let method = ConvMethod {
503            method: "to_i",
504            cstr,
505            id: 4,
506            conversion_type: ConversionType::Coercion,
507        };
508
509        assert!(method.is_coercion());
510        assert!(!method.is_implicit());
511    }
512
513    #[test]
514    fn test_get_or_init_populates_table() {
515        let mut symbols = SymbolTable::new();
516        let conv_methods = ConvMethods::new();
517
518        // Verify that the table is initially uninitialized
519        assert!(conv_methods.get().is_none());
520        assert!(conv_methods.table.get().is_none());
521
522        // Call `get_or_init` to populate the table
523        let result = conv_methods.get_or_init(&mut symbols);
524        assert!(result.is_ok());
525        let table = result.unwrap();
526
527        // Verify that the table was populated
528        assert_eq!(table.len(), 12);
529        assert!(conv_methods.get().is_some());
530        assert!(conv_methods.table.get().is_some());
531
532        // Ensure all symbols were interned
533        assert_eq!(symbols.len(), 12);
534    }
535
536    #[test]
537    fn test_find_method_existing_method() {
538        let mut symbols = SymbolTable::new();
539        let conv_methods = ConvMethods::new();
540
541        // Populate the table
542        assert!(conv_methods.get_or_init(&mut symbols).is_ok());
543
544        // Search for an existing method
545        let result = conv_methods.find_method(&mut symbols, "to_int");
546        assert!(result.is_ok());
547        let method = result.unwrap();
548        assert!(method.is_some());
549        assert_eq!(method.unwrap().method, "to_int");
550    }
551
552    #[test]
553    fn test_find_method_nonexistent_method() {
554        let mut symbols = SymbolTable::new();
555        let conv_methods = ConvMethods::new();
556
557        // Populate the table
558        assert!(conv_methods.get_or_init(&mut symbols).is_ok());
559
560        // Search for a non-existent method
561        let result = conv_methods.find_method(&mut symbols, "nonexistent_method");
562        assert!(result.is_ok());
563        assert!(result.unwrap().is_none());
564    }
565
566    #[test]
567    fn test_get_or_init_idempotent() {
568        let mut symbols = SymbolTable::new();
569        let conv_methods = ConvMethods::new();
570
571        // First initialization
572        let result1 = conv_methods.get_or_init(&mut symbols);
573        assert!(result1.is_ok());
574        let table1 = result1.unwrap();
575
576        // Second initialization
577        let result2 = conv_methods.get_or_init(&mut symbols);
578        assert!(result2.is_ok());
579        let table2 = result2.unwrap();
580
581        // Verify that both initializations return the same table reference
582        assert!(ptr::eq(table1, table2));
583    }
584
585    #[test]
586    fn seven_implicit_conversions() {
587        let mut symbols = SymbolTable::new();
588        let conv_methods = ConvMethods::new();
589        let table = conv_methods.get_or_init(&mut symbols).unwrap();
590        let mut iter = table.iter();
591
592        for conv in iter.by_ref().take(7) {
593            assert!(conv.is_implicit(), "{} should be implicit conversion", conv.method);
594            assert_eq!(conv.conversion_type, ConversionType::Implicit);
595        }
596
597        for conv in iter {
598            assert!(conv.is_coercion(), "{} should be coercion", conv.method);
599            assert_eq!(conv.conversion_type, ConversionType::Coercion);
600        }
601    }
602
603    #[test]
604    fn implicit_conversions_setup() {
605        let mut symbols = SymbolTable::new();
606        let conv_methods = ConvMethods::new();
607
608        for method in ["to_int", "to_ary", "to_str", "to_sym", "to_hash", "to_proc", "to_io"] {
609            let conv = conv_methods.find_method(&mut symbols, method).unwrap();
610            let Some(conv) = conv else {
611                panic!("conversion method {method} should be found");
612            };
613            assert!(conv.is_implicit(), "{method} should be implicit conversion");
614        }
615    }
616
617    #[test]
618    fn coercion_conversions_setup() {
619        let mut symbols = SymbolTable::new();
620        let conv_methods = ConvMethods::new();
621
622        for method in ["to_i", "to_s", "to_a", "to_f", "to_r"] {
623            let conv = conv_methods.find_method(&mut symbols, method).unwrap();
624            let Some(conv) = conv else {
625                panic!("conversion method {method} should be found");
626            };
627            assert!(conv.is_coercion(), "{method} should be coercion");
628        }
629    }
630
631    #[test]
632    fn array_is_fully_initialized() {
633        let mut symbols = SymbolTable::new();
634        let conv_methods = ConvMethods::new();
635        let table = conv_methods.get_or_init(&mut symbols).unwrap();
636        for conv in table {
637            assert!(!conv.method.is_empty());
638            assert!(!conv.cstr.to_bytes().is_empty());
639            assert_ne!(conv.id, u32::MAX);
640        }
641    }
642}