1
//! Pure protocol logic: classify an inbound SLYNK frame into a typed request,
2
//! and turn an `rpc::EvalOutcome` into the channel-send reply sequence. No I/O
3
//! here — the server loop ([`super`]) owns the socket and the `Session`.
4

            
5
use rpc::{EvalOutcome, ResponsePayload};
6
use scripting::nomiscript::format_value;
7

            
8
use super::events;
9
use super::sexp::Sexp;
10

            
11
/// A classified inbound message. Everything the server must act on; anything
12
/// unrecognised becomes [`Inbound::AbortRex`] (answered `:abort`) or
13
/// [`Inbound::Ignore`] so SLY never stalls.
14
#[derive(Debug, PartialEq)]
15
pub enum Inbound {
16
    /// `(:emacs-rex (slynk:connection-info) _ _ ID)`.
17
    ConnectionInfo { id: i64 },
18
    /// `(:emacs-rex (slynk:slynk-add-load-paths …) _ _ ID)`.
19
    AddLoadPaths { id: i64 },
20
    /// `(:emacs-rex (slynk:slynk-require …) _ _ ID)`.
21
    SlynkRequire { id: i64 },
22
    /// `(:emacs-rex (slynk-mrepl:create-mrepl LOCAL) _ _ ID)`.
23
    CreateMrepl { id: i64, local_channel: i64 },
24
    /// `(:emacs-rex (slynk:load-file "PATH") _ _ ID)` — `M-x sly-load-file`.
25
    LoadFile { id: i64, path: String },
26
    /// `(:emacs-rex (slynk-completion:{simple,flex}-completions "PREFIX" _) _ _ ID)`
27
    /// — symbol completion (mREPL TAB). `flex` selects the reply shape SLY's
28
    /// flex client expects (per-completion tuples) vs. simple's bare strings.
29
    Completions { id: i64, prefix: String, flex: bool },
30
    /// `(:emacs-channel-send CHAN (:process "SRC"))` — mREPL input.
31
    Process { channel: i64, source: String },
32
    /// `(:emacs-interrupt …)` — cancel the in-flight eval.
33
    Interrupt,
34
    /// `(:ping THREAD TAG)` — answer with a pong.
35
    Ping { thread: Sexp, tag: Sexp },
36
    /// A rex we don't implement — answer `(:return (:abort …) ID)`.
37
    AbortRex { id: i64 },
38
    /// A non-rex frame we can safely drop (no reply expected).
39
    Ignore,
40
}
41

            
42
/// Classifies a parsed inbound frame.
43
#[must_use]
44
11
pub fn classify(frame: &Sexp) -> Inbound {
45
11
    let Some(items) = frame.as_list() else {
46
        return Inbound::Ignore;
47
    };
48
11
    match items.first().and_then(Sexp::as_symbol) {
49
11
        Some(":emacs-rex") => classify_rex(items),
50
3
        Some(":emacs-channel-send") => classify_channel_send(items),
51
2
        Some(":emacs-interrupt") => Inbound::Interrupt,
52
1
        Some(":ping") => match (items.get(1), items.get(2)) {
53
1
            (Some(thread), Some(tag)) => Inbound::Ping {
54
1
                thread: thread.clone(),
55
1
                tag: tag.clone(),
56
1
            },
57
            _ => Inbound::Ignore,
58
        },
59
        _ => Inbound::Ignore,
60
    }
61
11
}
62

            
63
/// `(:emacs-rex FORM PACKAGE THREAD ID …)` — dispatch on FORM's head symbol.
64
/// Element layout: `[0]=:emacs-rex [1]=FORM [2]=PACKAGE [3]=THREAD [4]=ID`.
65
8
fn classify_rex(items: &[Sexp]) -> Inbound {
66
    // A malformed rex with no id can't be answered, so it's ignored.
67
8
    let Some(id) = items.get(4).and_then(Sexp::as_int) else {
68
        return Inbound::Ignore;
69
    };
70
8
    let head = items
71
8
        .get(1)
72
8
        .and_then(Sexp::as_list)
73
8
        .and_then(|f| f.first())
74
8
        .and_then(Sexp::as_symbol);
75
8
    match head {
76
8
        Some("slynk:connection-info") => Inbound::ConnectionInfo { id },
77
7
        Some("slynk:slynk-add-load-paths") => Inbound::AddLoadPaths { id },
78
6
        Some("slynk:slynk-require") => Inbound::SlynkRequire { id },
79
5
        Some("slynk-mrepl:create-mrepl") => {
80
1
            let local_channel = items
81
1
                .get(1)
82
1
                .and_then(Sexp::as_list)
83
1
                .and_then(|f| f.get(1))
84
1
                .and_then(Sexp::as_int)
85
1
                .unwrap_or(1);
86
1
            Inbound::CreateMrepl { id, local_channel }
87
        }
88
4
        Some("slynk:load-file") => {
89
1
            match items
90
1
                .get(1)
91
1
                .and_then(Sexp::as_list)
92
1
                .and_then(|f| f.get(1))
93
1
                .and_then(Sexp::as_str)
94
            {
95
1
                Some(path) => Inbound::LoadFile {
96
1
                    id,
97
1
                    path: path.to_string(),
98
1
                },
99
                None => Inbound::AbortRex { id },
100
            }
101
        }
102
        Some(
103
3
            name @ ("slynk-completion:simple-completions" | "slynk-completion:flex-completions"),
104
2
        ) => classify_completions(items, id, name == "slynk-completion:flex-completions"),
105
1
        _ => Inbound::AbortRex { id },
106
    }
107
8
}
108

            
109
/// `(slynk-completion:…-completions "PREFIX" 'PACKAGE)` — the prefix is the
110
/// first form argument. A non-string prefix can't be completed → `:abort`.
111
2
fn classify_completions(items: &[Sexp], id: i64, flex: bool) -> Inbound {
112
2
    match items
113
2
        .get(1)
114
2
        .and_then(Sexp::as_list)
115
2
        .and_then(|f| f.get(1))
116
2
        .and_then(Sexp::as_str)
117
    {
118
2
        Some(prefix) => Inbound::Completions {
119
2
            id,
120
2
            prefix: prefix.to_string(),
121
2
            flex,
122
2
        },
123
        None => Inbound::AbortRex { id },
124
    }
125
2
}
126

            
127
/// `(:emacs-channel-send CHAN (:process "SRC"))`.
128
1
fn classify_channel_send(items: &[Sexp]) -> Inbound {
129
1
    let Some(channel) = items.get(1).and_then(Sexp::as_int) else {
130
        return Inbound::Ignore;
131
    };
132
1
    let msg = items.get(2).and_then(Sexp::as_list);
133
1
    let is_process = msg.and_then(|m| m.first()).and_then(Sexp::as_symbol) == Some(":process");
134
1
    if is_process && let Some(source) = msg.and_then(|m| m.get(1)).and_then(Sexp::as_str) {
135
1
        return Inbound::Process {
136
1
            channel,
137
1
            source: source.to_string(),
138
1
        };
139
    }
140
    Inbound::Ignore
141
1
}
142

            
143
/// The channel-send reply sequence for one evaluated `:process` form:
144
/// optional `:write-string` (captured output), then `:write-values` (the value)
145
/// or `:evaluation-aborted` (an error), then a fresh `:prompt`.
146
#[must_use]
147
3
pub fn eval_reply(channel: i64, outcome: &EvalOutcome) -> Vec<String> {
148
3
    let mut out = Vec::new();
149
3
    if !outcome.output.is_empty() {
150
1
        out.push(events::write_string(channel, &outcome.output));
151
2
    }
152
3
    match &outcome.payload {
153
2
        ResponsePayload::Value(value) => {
154
2
            out.push(events::write_values(channel, &format_value(value)));
155
2
        }
156
1
        ResponsePayload::Error { message, .. } => {
157
1
            out.push(events::evaluation_aborted(channel, message));
158
1
        }
159
    }
160
3
    out.push(events::prompt(channel));
161
3
    out
162
3
}
163

            
164
/// The rex reply for a `slynk:load-file`: `(:return (:ok "<summary>") ID)` on
165
/// success, `(:return (:abort "<message>") ID)` on a read/parse/eval failure.
166
/// `sly-load-file` renders the `:ok` value in its transcript and SLY has no
167
/// top-level `:write-string` event, so any captured script output is prepended
168
/// to the summary here rather than sent as a separate (invalid) frame.
169
#[must_use]
170
4
pub fn load_reply(id: i64, outcome: &EvalOutcome) -> String {
171
2
    match &outcome.payload {
172
        // `handle_file` returns a `Value::String` summary; take its raw text so
173
        // `Sexp::Str` quotes it once (using `format_value` here would
174
        // double-quote — it renders a String as a quoted literal).
175
2
        ResponsePayload::Value(scripting::nomiscript::Value::String(summary)) => {
176
2
            events::return_ok(Sexp::Str(with_output(&outcome.output, summary)), id)
177
        }
178
        ResponsePayload::Value(value) => events::return_ok(
179
            Sexp::Str(with_output(&outcome.output, &format_value(value))),
180
            id,
181
        ),
182
        // Forms eval sequentially, so output printed before a failing form is
183
        // real; fold it into the abort reason too (SLY has no other channel for
184
        // it on a plain load-file rex) rather than silently dropping it.
185
2
        ResponsePayload::Error { message, .. } => {
186
2
            events::return_abort(&with_output(&outcome.output, message), id)
187
        }
188
    }
189
4
}
190

            
191
/// The rex reply for a completion request: `(:return (:ok (COMPLETIONS COMMON))
192
/// ID)`. The two SLYNK completion clients want different `COMPLETIONS` shapes:
193
/// simple takes bare strings + the longest common prefix; flex destructures each
194
/// entry as `(string score chunks classification suggestion)`, so we emit that
195
/// 5-tuple (score 1.0, no chunks/classification/suggestion) with `COMMON` nil.
196
#[must_use]
197
4
pub fn completion_reply(id: i64, prefix: &str, flex: bool, names: &[String]) -> String {
198
4
    let value = if flex {
199
2
        let entries = names
200
2
            .iter()
201
2
            .map(|name| {
202
1
                Sexp::List(vec![
203
1
                    Sexp::Str(name.clone()),
204
1
                    Sexp::Symbol("1.0".to_string()),
205
1
                    Sexp::List(Vec::new()),
206
1
                    Sexp::Symbol("nil".to_string()),
207
1
                    Sexp::Symbol("nil".to_string()),
208
1
                ])
209
1
            })
210
2
            .collect();
211
2
        Sexp::List(vec![Sexp::List(entries), Sexp::Symbol("nil".to_string())])
212
    } else {
213
2
        let strings = names.iter().map(|n| Sexp::Str(n.clone())).collect();
214
2
        let common = longest_common_prefix(names).unwrap_or_else(|| prefix.to_string());
215
2
        Sexp::List(vec![Sexp::List(strings), Sexp::Str(common)])
216
    };
217
4
    events::return_ok(value, id)
218
4
}
219

            
220
/// The longest common prefix of all `names`, or `None` when the list is empty
221
/// (simple-completions' COMMON slot, the string the input expands to). Compares
222
/// position-by-position over `char`s — never byte-slices the other names, whose
223
/// char boundaries needn't line up with the first's (a byte index from one
224
/// would panic on another for mixed-width UTF-8).
225
6
fn longest_common_prefix(names: &[String]) -> Option<String> {
226
6
    let first = names.first()?;
227
4
    let prefix: String = first
228
4
        .chars()
229
4
        .enumerate()
230
14
        .take_while(|&(pos, c)| names[1..].iter().all(|n| n.chars().nth(pos) == Some(c)))
231
4
        .map(|(_, c)| c)
232
4
        .collect();
233
4
    Some(prefix)
234
6
}
235

            
236
/// Prepends captured script output to a load summary (blank-line separated), so
237
/// `(print …)` during a load is visible in SLY's transcript. No output → the
238
/// summary verbatim.
239
4
fn with_output(output: &str, summary: &str) -> String {
240
4
    if output.is_empty() {
241
2
        summary.to_string()
242
    } else {
243
2
        format!("{output}\n{summary}")
244
    }
245
4
}
246

            
247
#[cfg(test)]
248
mod tests {
249
    use super::*;
250
    use crate::slynk::sexp::parse;
251
    use rpc::ErrorCode;
252

            
253
11
    fn classify_str(s: &str) -> Inbound {
254
11
        classify(&parse(s).unwrap())
255
11
    }
256

            
257
    #[test]
258
1
    fn classifies_connection_info() {
259
1
        assert_eq!(
260
1
            classify_str("(:emacs-rex (slynk:connection-info) nil t 1)"),
261
            Inbound::ConnectionInfo { id: 1 }
262
        );
263
1
    }
264

            
265
    #[test]
266
1
    fn classifies_add_load_paths_and_require() {
267
1
        assert_eq!(
268
1
            classify_str("(:emacs-rex (slynk:slynk-add-load-paths '(\"x\")) nil t 2)"),
269
            Inbound::AddLoadPaths { id: 2 }
270
        );
271
1
        assert_eq!(
272
1
            classify_str("(:emacs-rex (slynk:slynk-require '(\"slynk/mrepl\")) nil t 3)"),
273
            Inbound::SlynkRequire { id: 3 }
274
        );
275
1
    }
276

            
277
    #[test]
278
1
    fn classifies_create_mrepl_with_local_channel() {
279
1
        assert_eq!(
280
1
            classify_str("(:emacs-rex (slynk-mrepl:create-mrepl 1) nil t 4)"),
281
            Inbound::CreateMrepl {
282
                id: 4,
283
                local_channel: 1
284
            }
285
        );
286
1
    }
287

            
288
    #[test]
289
1
    fn classifies_process_input() {
290
1
        assert_eq!(
291
1
            classify_str("(:emacs-channel-send 1 (:process \"(+ 1 2)\"))"),
292
1
            Inbound::Process {
293
1
                channel: 1,
294
1
                source: "(+ 1 2)".into()
295
1
            }
296
        );
297
1
    }
298

            
299
    #[test]
300
1
    fn classifies_interrupt_and_ping() {
301
1
        assert_eq!(classify_str("(:emacs-interrupt nil)"), Inbound::Interrupt);
302
1
        assert!(matches!(classify_str("(:ping 1 5)"), Inbound::Ping { .. }));
303
1
    }
304

            
305
    #[test]
306
1
    fn classifies_load_file() {
307
1
        assert_eq!(
308
1
            classify_str("(:emacs-rex (slynk:load-file \"/tmp/x.nms\") nil t 8)"),
309
1
            Inbound::LoadFile {
310
1
                id: 8,
311
1
                path: "/tmp/x.nms".into()
312
1
            }
313
        );
314
1
    }
315

            
316
    #[test]
317
1
    fn classifies_simple_and_flex_completions() {
318
1
        assert_eq!(
319
1
            classify_str(
320
1
                "(:emacs-rex (slynk-completion:simple-completions \"def\" (quote nil)) nil t 9)"
321
            ),
322
1
            Inbound::Completions {
323
1
                id: 9,
324
1
                prefix: "def".into(),
325
1
                flex: false
326
1
            }
327
        );
328
1
        assert_eq!(
329
1
            classify_str(
330
1
                "(:emacs-rex (slynk-completion:flex-completions \"li\" (quote nil)) nil t 10)"
331
            ),
332
1
            Inbound::Completions {
333
1
                id: 10,
334
1
                prefix: "li".into(),
335
1
                flex: true
336
1
            }
337
        );
338
1
    }
339

            
340
    #[test]
341
1
    fn simple_completion_reply_has_strings_and_common_prefix() {
342
1
        let names = vec!["list".to_string(), "list-accounts".to_string()];
343
1
        let reply = completion_reply(11, "li", false, &names);
344
        // bare strings + the longest common prefix in the COMMON slot.
345
1
        assert_eq!(
346
            reply,
347
            "(:return (:ok ((\"list\" \"list-accounts\") \"list\")) 11)"
348
        );
349
1
    }
350

            
351
    #[test]
352
1
    fn flex_completion_reply_uses_per_entry_tuples() {
353
1
        let names = vec!["defun".to_string()];
354
1
        let reply = completion_reply(12, "def", true, &names);
355
        // each entry is (string score chunks classification suggestion); COMMON nil.
356
1
        assert_eq!(
357
            reply,
358
            "(:return (:ok (((\"defun\" 1.0 () nil nil)) nil)) 12)"
359
        );
360
1
    }
361

            
362
    #[test]
363
1
    fn empty_completion_reply_is_well_formed() {
364
1
        assert_eq!(
365
1
            completion_reply(13, "zzz", false, &[]),
366
            "(:return (:ok (() \"zzz\")) 13)"
367
        );
368
1
        assert_eq!(
369
1
            completion_reply(14, "zzz", true, &[]),
370
            "(:return (:ok (() nil)) 14)"
371
        );
372
1
    }
373

            
374
    #[test]
375
1
    fn longest_common_prefix_handles_mixed_width_unicode() {
376
        // Char boundaries differ between names (É is 2 bytes, € is 3): a
377
        // byte-index from the first name would land mid-codepoint in another and
378
        // panic. The char-wise comparison must not, and must find the shared "A".
379
1
        let names = vec!["AÉX".to_string(), "A€Y".to_string()];
380
1
        assert_eq!(longest_common_prefix(&names).as_deref(), Some("A"));
381
        // Full match and single-element cases.
382
1
        assert_eq!(
383
1
            longest_common_prefix(&["café".to_string(), "café".to_string()]).as_deref(),
384
            Some("café")
385
        );
386
1
        assert_eq!(
387
1
            longest_common_prefix(&["solo".to_string()]).as_deref(),
388
            Some("solo")
389
        );
390
1
        assert_eq!(longest_common_prefix(&[]).as_deref(), None);
391
1
    }
392

            
393
    #[test]
394
1
    fn load_reply_value_is_return_ok_string() {
395
1
        let outcome = EvalOutcome {
396
1
            output: String::new(),
397
1
            payload: ResponsePayload::Value(scripting::nomiscript::Value::String(
398
1
                "loaded /x (2 forms)".into(),
399
1
            )),
400
1
        };
401
1
        assert_eq!(
402
1
            load_reply(8, &outcome),
403
            "(:return (:ok \"loaded /x (2 forms)\") 8)"
404
        );
405
1
    }
406

            
407
    #[test]
408
1
    fn load_reply_folds_captured_output_into_summary() {
409
        // SLY has no top-level :write-string; load output must ride the :ok
410
        // value so `sly-load-file`'s transcript shows it.
411
1
        let outcome = EvalOutcome {
412
1
            output: "hello".into(),
413
1
            payload: ResponsePayload::Value(scripting::nomiscript::Value::String(
414
1
                "loaded /x (1 forms)".into(),
415
1
            )),
416
1
        };
417
        // A literal newline separates output from summary (valid in a Lisp
418
        // string; the writer only escapes `"` and `\`).
419
1
        assert_eq!(
420
1
            load_reply(8, &outcome),
421
            "(:return (:ok \"hello\nloaded /x (1 forms)\") 8)"
422
        );
423
1
    }
424

            
425
    #[test]
426
1
    fn load_reply_error_is_return_abort() {
427
1
        let outcome = EvalOutcome {
428
1
            output: String::new(),
429
1
            payload: ResponsePayload::Error {
430
1
                code: ErrorCode::new("compile"),
431
1
                message: "cannot read /x".into(),
432
1
                detail: None,
433
1
            },
434
1
        };
435
1
        assert_eq!(
436
1
            load_reply(8, &outcome),
437
            "(:return (:abort \"cannot read /x\") 8)"
438
        );
439
1
    }
440

            
441
    #[test]
442
1
    fn load_reply_error_keeps_output_printed_before_the_failure() {
443
        // A form printed before a later form errored; that output is real and
444
        // must survive into the abort reason, not be dropped.
445
1
        let outcome = EvalOutcome {
446
1
            output: "partial".into(),
447
1
            payload: ResponsePayload::Error {
448
1
                code: ErrorCode::new("compile"),
449
1
                message: "boom".into(),
450
1
                detail: None,
451
1
            },
452
1
        };
453
1
        assert_eq!(
454
1
            load_reply(8, &outcome),
455
            "(:return (:abort \"partial\nboom\") 8)"
456
        );
457
1
    }
458

            
459
    #[test]
460
1
    fn unknown_rex_aborts_with_id() {
461
1
        assert_eq!(
462
1
            classify_str("(:emacs-rex (slynk:autodoc nil) nil t 7)"),
463
            Inbound::AbortRex { id: 7 }
464
        );
465
1
    }
466

            
467
    #[test]
468
1
    fn eval_reply_value_has_write_values_then_prompt() {
469
1
        let outcome = EvalOutcome {
470
1
            output: String::new(),
471
1
            payload: ResponsePayload::Value(scripting::nomiscript::Value::Number(
472
1
                scripting::nomiscript::Fraction::from_integer(3),
473
1
            )),
474
1
        };
475
1
        let reply = eval_reply(1, &outcome);
476
1
        assert_eq!(reply.len(), 2);
477
1
        assert!(reply[0].contains("(:write-values ((\"3\" nil nil)))"));
478
1
        assert!(reply[1].contains(":prompt"));
479
1
    }
480

            
481
    #[test]
482
1
    fn eval_reply_with_output_prepends_write_string() {
483
1
        let outcome = EvalOutcome {
484
1
            output: "hi".into(),
485
1
            payload: ResponsePayload::Value(scripting::nomiscript::Value::Nil),
486
1
        };
487
1
        let reply = eval_reply(1, &outcome);
488
1
        assert_eq!(reply.len(), 3);
489
1
        assert!(reply[0].contains("(:write-string \"hi\")"));
490
1
        assert!(reply[1].contains(":write-values"));
491
1
        assert!(reply[2].contains(":prompt"));
492
1
    }
493

            
494
    #[test]
495
1
    fn eval_reply_error_is_evaluation_aborted() {
496
1
        let outcome = EvalOutcome {
497
1
            output: String::new(),
498
1
            payload: ResponsePayload::Error {
499
1
                code: ErrorCode::new("compile"),
500
1
                message: "boom".into(),
501
1
                detail: None,
502
1
            },
503
1
        };
504
1
        let reply = eval_reply(1, &outcome);
505
1
        assert!(reply[0].contains("(:evaluation-aborted \"boom\")"));
506
1
        assert!(reply[1].contains(":prompt"));
507
1
    }
508
}