tulisp 0.29.0

An embeddable lisp interpreter.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
//! Property-list (plist) primitives and the typed [`Plistable`] layer.
//!
//! A plist is a flat alternating-key/value list, e.g. `(:a 1 :b 2)`.
//! See [the Emacs Lisp manual] for the canonical reference.
//!
//! [the Emacs Lisp manual]: https://www.gnu.org/software/emacs/manual/html_node/elisp/Property-Lists.html

use std::ops::Deref;

use crate::{Error, TulispContext, TulispObject};

/// Makes a plist from the given arguments.
pub fn plist_from<const N: usize>(input: [(TulispObject, TulispObject); N]) -> TulispObject {
    let mut builder = crate::cons::ListBuilder::new();
    for (key, value) in input.into_iter() {
        builder.push(key);
        builder.push(value);
    }
    builder.build()
}

/// Returns the value of the property `property` stored in the property list
/// `plist`.
pub fn plist_get(plist: &TulispObject, property: &TulispObject) -> Result<TulispObject, Error> {
    // Floyd's tortoise / hare: hare advances by `cddr` (two cells) per
    // iteration — the natural plist step — and tortoise by `cdr`. If
    // they ever meet, the plist is circular. Mirrors `lists::length`
    // and `alist::assoc`.
    let mut slow = plist.clone();
    let mut cur = plist.clone();
    loop {
        if !cur.consp() {
            return Ok(TulispObject::nil());
        }
        if cur.car_and_then(|car| Ok(car.eq(property)))? {
            return cur.cadr();
        }
        cur = cur.cddr()?;
        slow = slow.cdr()?;
        if slow.eq_ptr(&cur) {
            return Err(Error::out_of_range("Circular plist".to_string()));
        }
    }
}

/// A typed wrapper around a Lisp plist, for use as a [`defun`](crate::TulispContext::defun) argument.
///
/// When `Plist<T>` appears as a parameter type, the function receives the
/// caller's entire argument list as a plist and deserializes it into `T`.
///
/// `T` must implement [`Plistable`], which is most easily done via the
/// [`AsPlist!`](macro@crate::AsPlist) macro.
///
/// `Plist<T>` implements [`Deref<Target = T>`], so fields of the inner struct
/// can be accessed directly.
///
/// # Example
///
/// ```rust
/// use tulisp::{TulispContext, Plist, Plistable, AsPlist};
///
/// AsPlist! {
///     struct Point { x: i64, y: i64 }
/// }
///
/// let mut ctx = TulispContext::new();
///
/// ctx.defun("distance", |p: Plist<Point>| -> f64 {
///     ((p.x * p.x + p.y * p.y) as f64).sqrt()
/// });
///
/// assert_eq!(
///     ctx.eval_string("(distance :x 3 :y 4)").unwrap().as_number().unwrap(),
///     5.0
/// );
/// ```
pub struct Plist<T: Plistable> {
    plist: T,
}

impl<T> Plist<T>
where
    T: Plistable,
{
    pub(crate) fn new(ctx: &mut TulispContext, args: &[TulispObject]) -> Result<Self, Error> {
        Ok(Self {
            plist: T::from_plist_as_slice(ctx, args)?,
        })
    }

    pub fn into_inner(self) -> T {
        self.plist
    }
}

impl<T> Deref for Plist<T>
where
    T: Plistable,
{
    type Target = T;

    fn deref(&self) -> &Self::Target {
        &self.plist
    }
}

/// Conversion between a Rust struct and a Lisp plist.
///
/// The plist's values are treated as already-evaluated lisp objects
/// and passed through to the field types' `TryFrom<TulispObject>` impls
/// without further evaluation — which is what you want for plists held
/// in free variables, literals, or any value already produced by the
/// interpreter:
///
/// ```ignore
/// let cfg = MyType::from_plist(&mut ctx, &obj)?;
/// ```
///
/// The [`AsPlist!`](macro@crate::AsPlist) macro generates both
/// `from_plist_as_slice` and `from_plist` from a struct definition.
pub trait Plistable {
    /// Deserialize `Self` from a flat `[k0, v0, k1, v1, …]` slice of
    /// already-evaluated lisp values. The defun-arg path (`Plist<T>`)
    /// calls this directly with the evaluated arg slice.
    fn from_plist_as_slice(ctx: &mut TulispContext, kvs: &[TulispObject]) -> Result<Self, Error>
    where
        Self: Sized;

    /// Deserialize an already-evaluated lisp plist value into `Self`.
    /// Iterates `obj.base_iter()` directly without an intermediate
    /// `Vec`.
    fn from_plist(ctx: &mut TulispContext, obj: &TulispObject) -> Result<Self, Error>
    where
        Self: Sized;

    /// Serialize `self` into a Lisp plist.
    fn into_plist(self, ctx: &mut TulispContext) -> TulispObject;
}

/// Derive [`Plistable`] for a struct, enabling it to be used as a [`Plist<T>`]
/// argument in [`defun`](crate::TulispContext::defun)-registered functions.
///
/// # Syntax
///
/// ```text
/// AsPlist! {
///     [attributes]
///     [pub] struct Name {
///         [field_vis] field[<":key-name">]: Type [{= default}],
///         ...
///     }
/// }
/// ```
///
/// - Each field maps to a plist keyword.  By default the keyword is
///   `":fieldname"`.  Use `field<":custom-key">` to override.
/// - A field with `{= expr}` is optional; if absent from the plist the
///   default expression is used.
/// - A field without a default is required; a missing key is an error.
///
/// # Example
///
/// ```rust
/// use tulisp::{TulispContext, Plist, AsPlist};
///
/// AsPlist! {
///     struct Config {
///         host: String,
///         port<":port-number">: i64 {= 8080},
///         scheme: Option<String> {= None},
///     }
/// }
///
/// let mut ctx = TulispContext::new();
/// ctx.defun("make-server", |cfg: Plist<Config>| -> String {
///     format!(
///         "{}://{}:{}",
///         cfg.scheme.clone().unwrap_or_else(||"http".to_string()),
///         cfg.host,
///         cfg.port
///     )
/// });
///
/// assert_eq!(
///    ctx.eval_string(r#"(make-server :host "localhost")"#).unwrap().as_string().unwrap(),
///    "http://localhost:8080"
/// );
/// assert_eq!(
///    ctx.eval_string(r#"(make-server :host "example.com" :port-number 443)"#).unwrap().as_string().unwrap(),
///    "http://example.com:443"
/// );
///
/// assert_eq!(
///    ctx.eval_string(r#"(make-server :host "localhost" :scheme "https")"#).unwrap().as_string().unwrap(),
///    "https://localhost:8080"
/// );
/// ```
#[macro_export]
macro_rules! AsPlist {
    (@key-name
        $field:ident<$field_key:literal>) => {
        $field_key
    };
    (@key-name $field:ident) => {
        concat!(":", stringify!($field))
    };

    (@missing-field $key_name:expr, $default:expr) => {
        Ok($default)
    };
    (@missing-field $key_name:expr,) => {
        Err($crate::Error::plist_error(concat!("Missing ", $key_name, " field")))
    };

    (@extract-field $value:ident, None) => {
        if $value.null() {
            None
        } else {
            Some($value.try_into()?)
        }
    };

    (@extract-field $value:ident, Some($e: expr)) => {
        if $value.null() {
            None
        } else {
            Some($value.try_into()?)
        }
    };

    (@extract-field $value:ident, $e: expr) => {
        $value.try_into()?
    };

    (@extract-field $value:ident) => {
        $value.try_into()?
    };

    (
        $( #[$meta:meta] )*
        $vis:vis struct $struct_name:ident {
            $(
                $( #[$($field_meta:tt)+] )*
                $field_vis:vis $field:ident$(<$field_key:literal>)? : $type:ty
                $({= $($default:tt)+ })?
            ),+ $(,)?
        }
    ) => {

        $( #[$meta] )*
        $vis struct $struct_name {

            $($( #[$($field_meta)+] )* $field_vis $field: $type),+
        }

        impl $crate::Plistable for $struct_name {
            fn from_plist_as_slice(
                ctx: &mut TulispContext,
                kvs: &[$crate::TulispObject],
            ) -> Result<Self, $crate::Error> {
                #[derive(Default)]
                struct Builder {
                    $($field: Option<$type>),+
                }

                impl Builder {
                    fn build(self) -> Result<$struct_name, $crate::Error> {
                        Ok($struct_name {
                            $($field: if let Some(f) = self.$field { f } else {
                                $crate::AsPlist!(
                                    @missing-field
                                    $crate::AsPlist!(@key-name $field $(<$field_key>)?),
                                    $( $($default)+ )?
                                )?}),+
                        })
                    }
                }

                let symbols = $crate::intern!(ctx => {
                    $($field: $crate::AsPlist!(@key-name $field $(<$field_key>)?)),+
                });

                let (kvpairs, extra) = kvs.as_chunks::<2>();

                if !extra.is_empty() {
                    return Err($crate::Error::plist_error(
                        "Expected an even number of items in the plist",
                    ));
                }

                let mut builder = Builder::default();

                for [key, value] in kvpairs {
                    $(if key.eq(&symbols.$field) {
                        let value = value.clone();
                        builder.$field = Some($crate::AsPlist!(@extract-field value $(, $($default)+)?));
                    } else)+ {
                        return Err($crate::Error::plist_error(format!(
                            "Unexpected key in plist: {}",
                            key
                        )));
                    }
                }

                builder.build()
            }

            fn from_plist(
                ctx: &mut TulispContext,
                obj: &$crate::TulispObject,
            ) -> Result<Self, $crate::Error> {
                #[derive(Default)]
                struct Builder {
                    $($field: Option<$type>),+
                }

                impl Builder {
                    fn build(self) -> Result<$struct_name, $crate::Error> {
                        Ok($struct_name {
                            $($field: if let Some(f) = self.$field { f } else {
                                $crate::AsPlist!(
                                    @missing-field
                                    $crate::AsPlist!(@key-name $field $(<$field_key>)?),
                                    $( $($default)+ )?
                                )?}),+
                        })
                    }
                }

                let symbols = $crate::intern!(ctx => {
                    $($field: $crate::AsPlist!(@key-name $field $(<$field_key>)?)),+
                });

                let mut iter = obj.base_iter();
                let mut builder = Builder::default();

                while let Some(key) = iter.next() {
                    let Some(value) = iter.next() else {
                        return Err($crate::Error::plist_error(
                            "Expected an even number of items in the plist",
                        ));
                    };
                    $(if key.eq(&symbols.$field) {
                        builder.$field = Some($crate::AsPlist!(@extract-field value $(, $($default)+)?));
                    } else)+ {
                        return Err($crate::Error::plist_error(format!(
                            "Unexpected key in plist: {}",
                            key
                        )));
                    }
                }

                builder.build()
            }

            fn into_plist(self, ctx: &mut TulispContext) -> $crate::TulispObject {
                let symbols = $crate::intern!(ctx => {
                    $($field: $crate::AsPlist!(@key-name $field $(<$field_key>)?)),+
                });

                $crate::plist::plist_from([
                    $((symbols.$field.clone(), self.$field.into())),+
                ])
            }
        }
     };
}

#[cfg(test)]
mod tests {
    use super::{plist_from, plist_get};
    use crate::{
        Error, Plist, Plistable, TulispContext,
        test_utils::{eval_assert_equal, eval_assert_error},
    };

    #[test]
    fn test_plist_primitives() -> Result<(), Error> {
        let mut ctx = TulispContext::new();
        let a = ctx.intern("a");
        let b = ctx.intern("b");
        let c = ctx.intern("c");
        let d = ctx.intern("d");
        let list = plist_from([
            (a.clone(), 20.into()),
            (b.clone(), 30.into()),
            (c.clone(), 40.into()),
        ]);
        assert!(plist_get(&list, &b)?.equal(&30.into()));
        assert!(plist_get(&list, &d)?.null());
        Ok(())
    }

    #[test]
    fn test_plist_get_detects_cycle() {
        // Build a circular plist: cdr of the tail cell points back to
        // the head, so iteration via `cddr` never reaches a non-cons.
        let mut ctx = TulispContext::new();
        ctx.eval_string(
            r#"
            (setq x (list :a 1 :b 2))
            (setcdr (cdr (cdr (cdr x))) x)
            "#,
        )
        .unwrap();
        let x = ctx.eval_string("x").unwrap();
        let missing = ctx.intern(":missing");
        let err = plist_get(&x, &missing).unwrap_err();
        let msg = err.format(&ctx);
        assert!(msg.contains("Circular plist"), "got: {msg}");
    }

    AsPlist! {
        #[derive(Default)]
        struct Person {
            /// The person's first name
            name<":first-name">: String,

            /// The person's age
            age: i64,

            /// The person's addresses
            addr: Vec<String>,

            /// The person's education (optional)
            education<":edu">: Option<String> {= None},

            /// The person's current place (optional, default is "Home")
            place: Option<String> {= Some("Home".to_string())},

            /// The person's answer to the ultimate question of life, the
            /// universe, and everything (optional, default is 42)
            answer: i64 {= 42},
        }
    }

    #[test]
    fn test_plistable() -> Result<(), Error> {
        let mut ctx = TulispContext::new();

        ctx.defun("get-name", |person: Plist<Person>| person.name.clone())
            .defun("get-age", |p: Plist<Person>| -> i64 { p.age })
            .defun("get-ans", |p: Plist<Person>| -> i64 { p.answer })
            .defun("get-place", |p: Plist<Person>| p.place.clone().unwrap())
            .defun("get-edu", |p: Plist<Person>| {
                p.education.clone().unwrap_or("Unknown".to_string())
            })
            .defun("get-addr", |p: Plist<Person>| {
                p.addr.last().unwrap().clone()
            });

        eval_assert_equal(
            &mut ctx,
            r#"(get-name :first-name "Alice" :age 30 :addr nil)"#,
            r#""Alice""#,
        );
        eval_assert_equal(
            &mut ctx,
            r#"(get-age :first-name "Alice" :age 30 :addr nil)"#,
            r#"30"#,
        );

        eval_assert_equal(
            &mut ctx,
            r#"(get-edu :first-name "Alice" :age 30 :addr nil)"#,
            r#""Unknown""#,
        );
        eval_assert_equal(
            &mut ctx,
            r#"(get-edu :first-name "Alice" :age 30 :addr nil :edu "School")"#,
            r#""School""#,
        );

        // Explicit nil for an optional field is read as `None`, not as
        // a value to extract via `try_into::<String>()`. (Before the
        // nil-handling fix this errored with "expected string, got: nil".)
        eval_assert_equal(
            &mut ctx,
            r#"(get-edu :first-name "Alice" :age 30 :addr nil :edu nil)"#,
            r#""Unknown""#,
        );

        eval_assert_equal(
            &mut ctx,
            r#"(get-place :first-name "Alice" :age 30 :addr nil)"#,
            r#""Home""#,
        );
        eval_assert_equal(
            &mut ctx,
            r#"(get-place :first-name "Alice" :age 30 :addr nil :place "Office")"#,
            r#""Office""#,
        );

        eval_assert_equal(
            &mut ctx,
            r#"(get-ans :first-name "Alice" :age 30 :addr nil)"#,
            r#"42"#,
        );
        eval_assert_equal(
            &mut ctx,
            r#"(get-ans :first-name "Alice" :age 30 :addr nil :place "Office" :answer 5)"#,
            r#"5"#,
        );

        eval_assert_equal(
            &mut ctx,
            r#"(get-addr :first-name "Alice" :age 30 :addr '("street" "other street"))"#,
            r#""other street""#,
        );
        eval_assert_equal(
            &mut ctx,
            r#"(get-addr :first-name "Alice" :age 30 :addr '("street" "other street"))"#,
            r#""other street""#,
        );

        eval_assert_error(
            &mut ctx,
            r#"(get-age :first-name "Alice" :age 30 :other 10)"#,
            r#"ERR PlistError: Unexpected key in plist: :other
<eval_string>:1.1-1.47:  at (get-age :first-name "Alice" :age 30 :other 10)
"#,
        );

        eval_assert_error(
            &mut ctx,
            r#"(get-age :first-name "Alice")"#,
            r#"ERR PlistError: Missing :age field
<eval_string>:1.1-1.29:  at (get-age :first-name "Alice")
"#,
        );

        Ok(())
    }

    #[test]
    fn test_plistable_round_trip() -> Result<(), Error> {
        let mut ctx = TulispContext::new();
        let p = Person {
            name: "Carol".into(),
            age: 40,
            addr: vec!["Pine St".into()],
            education: None,
            place: Some("Home".into()),
            answer: 42,
        };
        let obj = p.into_plist(&mut ctx);
        let q = Person::from_plist(&mut ctx, &obj)?;
        assert_eq!(q.name, "Carol");
        assert_eq!(q.age, 40);
        assert_eq!(q.addr, vec!["Pine St".to_string()]);
        // None serialises as nil, and the macro reads nil back as None
        // for Optional fields (instead of erroring on `try_into::<T>`).
        assert_eq!(q.education, None);
        assert_eq!(q.place.as_deref(), Some("Home"));
        assert_eq!(q.answer, 42);
        Ok(())
    }

    #[test]
    fn test_plistable_from_lisp_value_no_eval() -> Result<(), Error> {
        // A plist held in a free variable contains already-evaluated
        // values. `from_plist` should pass each value straight through
        // to its `TryFrom<TulispObject>` impl with no further
        // evaluation — so a list field like `addr` resolves cleanly
        // instead of erroring on "calling string as function".
        let mut ctx = TulispContext::new();
        ctx.eval_string(
            r#"(setq x '(:first-name "Bob"
                          :age 25
                          :addr ("Main St" "Oak Ave")
                          :place "Office"))"#,
        )?;
        let x = ctx.eval_string("x")?;

        // The default `from_plist` shortcut uses DummyEval.
        let p = Person::from_plist(&mut ctx, &x)?;
        assert_eq!(p.name, "Bob");
        assert_eq!(p.age, 25);
        assert_eq!(p.addr, vec!["Main St".to_string(), "Oak Ave".to_string()]);
        assert_eq!(p.place.as_deref(), Some("Office"));
        // `:edu` is omitted — its default kicks in.
        assert_eq!(p.education, None);

        Ok(())
    }
}