Lines
96 %
Functions
50 %
Branches
100 %
//! Pure protocol logic: classify an inbound SLYNK frame into a typed request,
//! and turn an `rpc::EvalOutcome` into the channel-send reply sequence. No I/O
//! here — the server loop ([`super`]) owns the socket and the `Session`.
use rpc::{EvalOutcome, ResponsePayload};
use scripting::nomiscript::format_value;
use super::events;
use super::sexp::Sexp;
/// A classified inbound message. Everything the server must act on; anything
/// unrecognised becomes [`Inbound::AbortRex`] (answered `:abort`) or
/// [`Inbound::Ignore`] so SLY never stalls.
#[derive(Debug, PartialEq)]
pub enum Inbound {
/// `(:emacs-rex (slynk:connection-info) _ _ ID)`.
ConnectionInfo { id: i64 },
/// `(:emacs-rex (slynk:slynk-add-load-paths …) _ _ ID)`.
AddLoadPaths { id: i64 },
/// `(:emacs-rex (slynk:slynk-require …) _ _ ID)`.
SlynkRequire { id: i64 },
/// `(:emacs-rex (slynk-mrepl:create-mrepl LOCAL) _ _ ID)`.
CreateMrepl { id: i64, local_channel: i64 },
/// `(:emacs-rex (slynk:load-file "PATH") _ _ ID)` — `M-x sly-load-file`.
LoadFile { id: i64, path: String },
/// `(:emacs-rex (slynk-completion:{simple,flex}-completions "PREFIX" _) _ _ ID)`
/// — symbol completion (mREPL TAB). `flex` selects the reply shape SLY's
/// flex client expects (per-completion tuples) vs. simple's bare strings.
Completions { id: i64, prefix: String, flex: bool },
/// `(:emacs-channel-send CHAN (:process "SRC"))` — mREPL input.
Process { channel: i64, source: String },
/// `(:emacs-interrupt …)` — cancel the in-flight eval.
Interrupt,
/// `(:ping THREAD TAG)` — answer with a pong.
Ping { thread: Sexp, tag: Sexp },
/// A rex we don't implement — answer `(:return (:abort …) ID)`.
AbortRex { id: i64 },
/// A non-rex frame we can safely drop (no reply expected).
Ignore,
}
/// Classifies a parsed inbound frame.
#[must_use]
pub fn classify(frame: &Sexp) -> Inbound {
let Some(items) = frame.as_list() else {
return Inbound::Ignore;
};
match items.first().and_then(Sexp::as_symbol) {
Some(":emacs-rex") => classify_rex(items),
Some(":emacs-channel-send") => classify_channel_send(items),
Some(":emacs-interrupt") => Inbound::Interrupt,
Some(":ping") => match (items.get(1), items.get(2)) {
(Some(thread), Some(tag)) => Inbound::Ping {
thread: thread.clone(),
tag: tag.clone(),
},
_ => Inbound::Ignore,
/// `(:emacs-rex FORM PACKAGE THREAD ID …)` — dispatch on FORM's head symbol.
/// Element layout: `[0]=:emacs-rex [1]=FORM [2]=PACKAGE [3]=THREAD [4]=ID`.
fn classify_rex(items: &[Sexp]) -> Inbound {
// A malformed rex with no id can't be answered, so it's ignored.
let Some(id) = items.get(4).and_then(Sexp::as_int) else {
let head = items
.get(1)
.and_then(Sexp::as_list)
.and_then(|f| f.first())
.and_then(Sexp::as_symbol);
match head {
Some("slynk:connection-info") => Inbound::ConnectionInfo { id },
Some("slynk:slynk-add-load-paths") => Inbound::AddLoadPaths { id },
Some("slynk:slynk-require") => Inbound::SlynkRequire { id },
Some("slynk-mrepl:create-mrepl") => {
let local_channel = items
.and_then(|f| f.get(1))
.and_then(Sexp::as_int)
.unwrap_or(1);
Inbound::CreateMrepl { id, local_channel }
Some("slynk:load-file") => {
match items
.and_then(Sexp::as_str)
{
Some(path) => Inbound::LoadFile {
id,
path: path.to_string(),
None => Inbound::AbortRex { id },
Some(
name @ ("slynk-completion:simple-completions" | "slynk-completion:flex-completions"),
) => classify_completions(items, id, name == "slynk-completion:flex-completions"),
_ => Inbound::AbortRex { id },
/// `(slynk-completion:…-completions "PREFIX" 'PACKAGE)` — the prefix is the
/// first form argument. A non-string prefix can't be completed → `:abort`.
fn classify_completions(items: &[Sexp], id: i64, flex: bool) -> Inbound {
Some(prefix) => Inbound::Completions {
prefix: prefix.to_string(),
flex,
/// `(:emacs-channel-send CHAN (:process "SRC"))`.
fn classify_channel_send(items: &[Sexp]) -> Inbound {
let Some(channel) = items.get(1).and_then(Sexp::as_int) else {
let msg = items.get(2).and_then(Sexp::as_list);
let is_process = msg.and_then(|m| m.first()).and_then(Sexp::as_symbol) == Some(":process");
if is_process && let Some(source) = msg.and_then(|m| m.get(1)).and_then(Sexp::as_str) {
return Inbound::Process {
channel,
source: source.to_string(),
Inbound::Ignore
/// The channel-send reply sequence for one evaluated `:process` form:
/// optional `:write-string` (captured output), then `:write-values` (the value)
/// or `:evaluation-aborted` (an error), then a fresh `:prompt`.
pub fn eval_reply(channel: i64, outcome: &EvalOutcome) -> Vec<String> {
let mut out = Vec::new();
if !outcome.output.is_empty() {
out.push(events::write_string(channel, &outcome.output));
match &outcome.payload {
ResponsePayload::Value(value) => {
out.push(events::write_values(channel, &format_value(value)));
ResponsePayload::Error { message, .. } => {
out.push(events::evaluation_aborted(channel, message));
out.push(events::prompt(channel));
out
/// The rex reply for a `slynk:load-file`: `(:return (:ok "<summary>") ID)` on
/// success, `(:return (:abort "<message>") ID)` on a read/parse/eval failure.
/// `sly-load-file` renders the `:ok` value in its transcript and SLY has no
/// top-level `:write-string` event, so any captured script output is prepended
/// to the summary here rather than sent as a separate (invalid) frame.
pub fn load_reply(id: i64, outcome: &EvalOutcome) -> String {
// `handle_file` returns a `Value::String` summary; take its raw text so
// `Sexp::Str` quotes it once (using `format_value` here would
// double-quote — it renders a String as a quoted literal).
ResponsePayload::Value(scripting::nomiscript::Value::String(summary)) => {
events::return_ok(Sexp::Str(with_output(&outcome.output, summary)), id)
ResponsePayload::Value(value) => events::return_ok(
Sexp::Str(with_output(&outcome.output, &format_value(value))),
),
// Forms eval sequentially, so output printed before a failing form is
// real; fold it into the abort reason too (SLY has no other channel for
// it on a plain load-file rex) rather than silently dropping it.
events::return_abort(&with_output(&outcome.output, message), id)
/// The rex reply for a completion request: `(:return (:ok (COMPLETIONS COMMON))
/// ID)`. The two SLYNK completion clients want different `COMPLETIONS` shapes:
/// simple takes bare strings + the longest common prefix; flex destructures each
/// entry as `(string score chunks classification suggestion)`, so we emit that
/// 5-tuple (score 1.0, no chunks/classification/suggestion) with `COMMON` nil.
pub fn completion_reply(id: i64, prefix: &str, flex: bool, names: &[String]) -> String {
let value = if flex {
let entries = names
.iter()
.map(|name| {
Sexp::List(vec![
Sexp::Str(name.clone()),
Sexp::Symbol("1.0".to_string()),
Sexp::List(Vec::new()),
Sexp::Symbol("nil".to_string()),
])
})
.collect();
Sexp::List(vec![Sexp::List(entries), Sexp::Symbol("nil".to_string())])
} else {
let strings = names.iter().map(|n| Sexp::Str(n.clone())).collect();
let common = longest_common_prefix(names).unwrap_or_else(|| prefix.to_string());
Sexp::List(vec![Sexp::List(strings), Sexp::Str(common)])
events::return_ok(value, id)
/// The longest common prefix of all `names`, or `None` when the list is empty
/// (simple-completions' COMMON slot, the string the input expands to). Compares
/// position-by-position over `char`s — never byte-slices the other names, whose
/// char boundaries needn't line up with the first's (a byte index from one
/// would panic on another for mixed-width UTF-8).
fn longest_common_prefix(names: &[String]) -> Option<String> {
let first = names.first()?;
let prefix: String = first
.chars()
.enumerate()
.take_while(|&(pos, c)| names[1..].iter().all(|n| n.chars().nth(pos) == Some(c)))
.map(|(_, c)| c)
Some(prefix)
/// Prepends captured script output to a load summary (blank-line separated), so
/// `(print …)` during a load is visible in SLY's transcript. No output → the
/// summary verbatim.
fn with_output(output: &str, summary: &str) -> String {
if output.is_empty() {
summary.to_string()
format!("{output}\n{summary}")
#[cfg(test)]
mod tests {
use super::*;
use crate::slynk::sexp::parse;
use rpc::ErrorCode;
fn classify_str(s: &str) -> Inbound {
classify(&parse(s).unwrap())
#[test]
fn classifies_connection_info() {
assert_eq!(
classify_str("(:emacs-rex (slynk:connection-info) nil t 1)"),
Inbound::ConnectionInfo { id: 1 }
);
fn classifies_add_load_paths_and_require() {
classify_str("(:emacs-rex (slynk:slynk-add-load-paths '(\"x\")) nil t 2)"),
Inbound::AddLoadPaths { id: 2 }
classify_str("(:emacs-rex (slynk:slynk-require '(\"slynk/mrepl\")) nil t 3)"),
Inbound::SlynkRequire { id: 3 }
fn classifies_create_mrepl_with_local_channel() {
classify_str("(:emacs-rex (slynk-mrepl:create-mrepl 1) nil t 4)"),
Inbound::CreateMrepl {
id: 4,
local_channel: 1
fn classifies_process_input() {
classify_str("(:emacs-channel-send 1 (:process \"(+ 1 2)\"))"),
Inbound::Process {
channel: 1,
source: "(+ 1 2)".into()
fn classifies_interrupt_and_ping() {
assert_eq!(classify_str("(:emacs-interrupt nil)"), Inbound::Interrupt);
assert!(matches!(classify_str("(:ping 1 5)"), Inbound::Ping { .. }));
fn classifies_load_file() {
classify_str("(:emacs-rex (slynk:load-file \"/tmp/x.nms\") nil t 8)"),
Inbound::LoadFile {
id: 8,
path: "/tmp/x.nms".into()
fn classifies_simple_and_flex_completions() {
classify_str(
"(:emacs-rex (slynk-completion:simple-completions \"def\" (quote nil)) nil t 9)"
Inbound::Completions {
id: 9,
prefix: "def".into(),
flex: false
"(:emacs-rex (slynk-completion:flex-completions \"li\" (quote nil)) nil t 10)"
id: 10,
prefix: "li".into(),
flex: true
fn simple_completion_reply_has_strings_and_common_prefix() {
let names = vec!["list".to_string(), "list-accounts".to_string()];
let reply = completion_reply(11, "li", false, &names);
// bare strings + the longest common prefix in the COMMON slot.
reply,
"(:return (:ok ((\"list\" \"list-accounts\") \"list\")) 11)"
fn flex_completion_reply_uses_per_entry_tuples() {
let names = vec!["defun".to_string()];
let reply = completion_reply(12, "def", true, &names);
// each entry is (string score chunks classification suggestion); COMMON nil.
"(:return (:ok (((\"defun\" 1.0 () nil nil)) nil)) 12)"
fn empty_completion_reply_is_well_formed() {
completion_reply(13, "zzz", false, &[]),
"(:return (:ok (() \"zzz\")) 13)"
completion_reply(14, "zzz", true, &[]),
"(:return (:ok (() nil)) 14)"
fn longest_common_prefix_handles_mixed_width_unicode() {
// Char boundaries differ between names (É is 2 bytes, € is 3): a
// byte-index from the first name would land mid-codepoint in another and
// panic. The char-wise comparison must not, and must find the shared "A".
let names = vec!["AÉX".to_string(), "A€Y".to_string()];
assert_eq!(longest_common_prefix(&names).as_deref(), Some("A"));
// Full match and single-element cases.
longest_common_prefix(&["café".to_string(), "café".to_string()]).as_deref(),
Some("café")
longest_common_prefix(&["solo".to_string()]).as_deref(),
Some("solo")
assert_eq!(longest_common_prefix(&[]).as_deref(), None);
fn load_reply_value_is_return_ok_string() {
let outcome = EvalOutcome {
output: String::new(),
payload: ResponsePayload::Value(scripting::nomiscript::Value::String(
"loaded /x (2 forms)".into(),
)),
load_reply(8, &outcome),
"(:return (:ok \"loaded /x (2 forms)\") 8)"
fn load_reply_folds_captured_output_into_summary() {
// SLY has no top-level :write-string; load output must ride the :ok
// value so `sly-load-file`'s transcript shows it.
output: "hello".into(),
"loaded /x (1 forms)".into(),
// A literal newline separates output from summary (valid in a Lisp
// string; the writer only escapes `"` and `\`).
"(:return (:ok \"hello\nloaded /x (1 forms)\") 8)"
fn load_reply_error_is_return_abort() {
payload: ResponsePayload::Error {
code: ErrorCode::new("compile"),
message: "cannot read /x".into(),
detail: None,
"(:return (:abort \"cannot read /x\") 8)"
fn load_reply_error_keeps_output_printed_before_the_failure() {
// A form printed before a later form errored; that output is real and
// must survive into the abort reason, not be dropped.
output: "partial".into(),
message: "boom".into(),
"(:return (:abort \"partial\nboom\") 8)"
fn unknown_rex_aborts_with_id() {
classify_str("(:emacs-rex (slynk:autodoc nil) nil t 7)"),
Inbound::AbortRex { id: 7 }
fn eval_reply_value_has_write_values_then_prompt() {
payload: ResponsePayload::Value(scripting::nomiscript::Value::Number(
scripting::nomiscript::Fraction::from_integer(3),
let reply = eval_reply(1, &outcome);
assert_eq!(reply.len(), 2);
assert!(reply[0].contains("(:write-values ((\"3\" nil nil)))"));
assert!(reply[1].contains(":prompt"));
fn eval_reply_with_output_prepends_write_string() {
output: "hi".into(),
payload: ResponsePayload::Value(scripting::nomiscript::Value::Nil),
assert_eq!(reply.len(), 3);
assert!(reply[0].contains("(:write-string \"hi\")"));
assert!(reply[1].contains(":write-values"));
assert!(reply[2].contains(":prompt"));
fn eval_reply_error_is_evaluation_aborted() {
assert!(reply[0].contains("(:evaluation-aborted \"boom\")"));