Skip to main content

escouade_plugin_protocol/
lib.rs

1#![doc = include_str!("../README.md")]
2//!
3//! ## Internals
4//!
5//! The protocol Escouade speaks to its plugins (docs/PLUGINS.md §3, schemas/plugin-protocol-1.json): JSON-RPC 2.0,
6//! one message a line, on the plugin's input and output. This crate holds what both sides share: the version, the
7//! names of the methods every plugin answers, the messages of the handshake, and `serve`, the loop a plugin written
8//! in Rust runs (`escouade-plugins`, the program of the plugins the app gives, INT-4).
9
10pub mod engine;
11pub mod forge;
12pub mod tickets;
13pub mod turn;
14
15use serde::{Deserialize, Serialize};
16use serde_json::{json, Value};
17use std::io::{BufRead, Write};
18use std::sync::{Arc, Mutex, OnceLock};
19
20/// The major version of the protocol, which `escouade.api` of a manifest and `protocolVersion` of the handshake name.
21pub const PROTOCOL: u64 = 1;
22
23/// The methods Escouade calls on every plugin.
24pub mod method {
25    /// Comes first: the version of the protocol, who speaks, what is on offer.
26    pub const INITIALIZE: &str = "initialize";
27    /// Comes last: the plugin answers, then exits.
28    pub const SHUTDOWN: &str = "shutdown";
29}
30
31/// The codes of a refusal (JSON-RPC's own).
32pub mod code {
33    pub const PARSE: i64 = -32700;
34    pub const METHOD_NOT_FOUND: i64 = -32601;
35    /// A refusal of the service that asking again would not change (a ticket's state no transition leads to).
36    pub const REFUSED: i64 = -32010;
37
38    /// The code with which a plugin says the HTTP error `status` of its service: `-(32000 + status)` (401 is
39    /// -32401, « reconnect the account »).
40    pub fn http(status: u16) -> i64 {
41        -(32_000 + i64::from(status))
42    }
43
44    /// The HTTP status `code` says, when it is one of `http`'s.
45    pub fn http_status(code: i64) -> Option<u16> {
46        u16::try_from(-code - 32_000)
47            .ok()
48            .filter(|s| (400..600).contains(s))
49    }
50}
51
52/// What Escouade says first (`initialize`).
53#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
54#[serde(rename_all = "camelCase")]
55pub struct Hello {
56    pub protocol_version: u64,
57    pub escouade: Version,
58    pub plugin: Named,
59    /// The language of the interface (`fr`, `en`), in which the plugin writes what the user reads: what a module cannot
60    /// get from its environment (docs/PLUGINS.md §8.4.8).
61    #[serde(default, skip_serializing_if = "Option::is_none")]
62    pub lang: Option<String>,
63    #[serde(default)]
64    pub capabilities: Value,
65}
66
67#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
68pub struct Version {
69    pub version: String,
70}
71
72#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
73pub struct Named {
74    pub id: String,
75    pub version: String,
76}
77
78impl Hello {
79    /// The handshake Escouade `version` sends to the plugin `id` at `plugin_version`.
80    pub fn new(version: &str, id: &str, plugin_version: &str, capabilities: Value) -> Self {
81        Self {
82            protocol_version: PROTOCOL,
83            escouade: Version {
84                version: version.into(),
85            },
86            plugin: Named {
87                id: id.into(),
88                version: plugin_version.into(),
89            },
90            lang: None,
91            capabilities,
92        }
93    }
94}
95
96/// What Escouade said of itself at `initialize`, kept by [`greeted`]: what a process reads from its environment, a module
97/// has no other way to get (docs/PLUGINS.md §8.4.8).
98#[derive(Debug, Clone, PartialEq, Default)]
99pub struct Greeting {
100    /// The language of the interface, when it was said.
101    pub lang: Option<String>,
102    /// The version of Escouade.
103    pub version: String,
104}
105
106impl Greeting {
107    fn of(params: &Value) -> Self {
108        Self {
109            lang: params["lang"].as_str().map(str::to_string),
110            version: params["escouade"]["version"]
111                .as_str()
112                .unwrap_or_default()
113                .to_string(),
114        }
115    }
116}
117
118static GREETING: OnceLock<Greeting> = OnceLock::new();
119
120/// What Escouade said at `initialize`: none before it. For the language of a plugin of the kit, see its `i18n`.
121pub fn greeting() -> Option<&'static Greeting> {
122    GREETING.get()
123}
124
125/// Keeps what the params of `initialize` say of Escouade (`serve`, the kit's loop). Only the first counts: a process, or
126/// an instance, is told once.
127pub fn greeted(params: &Value) {
128    let _ = GREETING.set(Greeting::of(params));
129}
130
131/// What the plugin answers to `initialize`: the version of the protocol it speaks (Escouade refuses any other) and
132/// what it can do.
133pub fn initialized(capabilities: Value) -> Value {
134    json!({ "protocolVersion": PROTOCOL, "capabilities": capabilities })
135}
136
137/// A request or a notification, read from a line.
138#[derive(Debug, Clone, PartialEq, Deserialize)]
139pub struct Request {
140    #[serde(default)]
141    pub id: Option<Value>,
142    pub method: String,
143    #[serde(default)]
144    pub params: Value,
145}
146
147/// A refusal: its code and its message.
148#[derive(Debug, Clone, PartialEq)]
149pub struct Refusal {
150    pub code: i64,
151    pub message: String,
152}
153
154impl Refusal {
155    pub fn not_found(method: &str) -> Self {
156        Self {
157            code: code::METHOD_NOT_FOUND,
158            message: format!("no method {method}"),
159        }
160    }
161}
162
163/// The answer to a request: its result, or its refusal.
164pub fn answer(id: &Value, result: Result<Value, Refusal>) -> String {
165    match result {
166        Ok(result) => json!({ "jsonrpc": "2.0", "id": id, "result": result }),
167        Err(e) => {
168            json!({ "jsonrpc": "2.0", "id": id, "error": { "code": e.code, "message": e.message } })
169        }
170    }
171    .to_string()
172}
173
174/// The loop of a plugin: reads a line at a time from `input`, answers the requests on `output`, calls `handler` for
175/// each method but the two of the handshake it answers itself (`initialize` with `capabilities`, `shutdown`, after
176/// which it returns). A line that is no request is answered with a parse error. Returns when `input` ends or after
177/// `shutdown`.
178pub fn serve<R: BufRead, W: Write>(
179    input: R,
180    mut output: W,
181    capabilities: Value,
182    mut handler: impl FnMut(&str, Value) -> Result<Value, Refusal>,
183) -> std::io::Result<()> {
184    for line in input.lines() {
185        let line = line?;
186        if line.trim().is_empty() {
187            continue;
188        }
189        let Ok(request) = serde_json::from_str::<Request>(&line) else {
190            let refusal = Refusal {
191                code: code::PARSE,
192                message: "not a JSON-RPC request".into(),
193            };
194            writeln!(output, "{}", answer(&Value::Null, Err(refusal)))?;
195            output.flush()?;
196            continue;
197        };
198        let result = match request.method.as_str() {
199            method::INITIALIZE => {
200                greeted(&request.params);
201                Ok(initialized(capabilities.clone()))
202            }
203            method::SHUTDOWN => Ok(Value::Null),
204            other => handler(other, request.params),
205        };
206        // A notification has no id: nothing is answered.
207        if let Some(id) = &request.id {
208            writeln!(output, "{}", answer(id, result))?;
209            output.flush()?;
210        }
211        if request.method == method::SHUTDOWN {
212            return Ok(());
213        }
214    }
215    Ok(())
216}
217
218/// The output of a plugin that answers later, or says something without being asked: a line at a time, from any thread.
219#[derive(Clone)]
220pub struct Output(Arc<Mutex<Box<dyn Write + Send>>>);
221
222impl Output {
223    pub fn new(out: impl Write + Send + 'static) -> Self {
224        Self(Arc::new(Mutex::new(Box::new(out))))
225    }
226
227    fn line(&self, text: &str) {
228        let mut out = self.0.lock().unwrap_or_else(|e| e.into_inner());
229        // The core is gone: its end closes this process's input, which ends its loop.
230        let _ = writeln!(out, "{text}").and_then(|()| out.flush());
231    }
232
233    /// Tells the core something, without waiting for an answer.
234    pub fn notify(&self, method: &str, params: &impl Serialize) {
235        self.line(&json!({ "jsonrpc": "2.0", "method": method, "params": params }).to_string());
236    }
237}
238
239/// How a request is answered, now or later; dropped, it is not (a notification has nothing to answer).
240#[derive(Clone)]
241pub struct Reply {
242    id: Option<Value>,
243    out: Output,
244}
245
246impl Reply {
247    pub fn send(&self, result: Result<Value, Refusal>) {
248        if let Some(id) = &self.id {
249            self.out.line(&answer(id, result));
250        }
251    }
252}
253
254/// `serve` for a plugin whose answers may wait (an HTTP client): `handler` answers by its `Reply`, from wherever it wants,
255/// and goes back to reading at once, so that a long call does not hold the others.
256pub fn serve_later<R: BufRead>(
257    input: R,
258    output: Output,
259    capabilities: Value,
260    mut handler: impl FnMut(&str, Value, Reply),
261) -> std::io::Result<()> {
262    for line in input.lines() {
263        let line = line?;
264        if line.trim().is_empty() {
265            continue;
266        }
267        let Ok(request) = serde_json::from_str::<Request>(&line) else {
268            let refusal = Refusal {
269                code: code::PARSE,
270                message: "not a JSON-RPC request".into(),
271            };
272            output.line(&answer(&Value::Null, Err(refusal)));
273            continue;
274        };
275        let reply = Reply {
276            id: request.id.clone(),
277            out: output.clone(),
278        };
279        match request.method.as_str() {
280            method::INITIALIZE => {
281                greeted(&request.params);
282                reply.send(Ok(initialized(capabilities.clone())));
283            }
284            method::SHUTDOWN => {
285                reply.send(Ok(Value::Null));
286                return Ok(());
287            }
288            other => handler(other, request.params, reply),
289        }
290    }
291    Ok(())
292}
293
294/// The JSON value of a harness's line. A harness written in JavaScript escapes a lone half of a
295/// UTF-16 pair (`\ud83c`), which JSON allows and serde refuses: the output of a tool that read a
296/// binary file was lost whole (ESC-290). Such a half is read as `�` (�).
297pub fn json_line(line: &str) -> serde_json::Result<serde_json::Value> {
298    match serde_json::from_str(line) {
299        Err(e) if line.contains("\\ud") || line.contains("\\uD") => {
300            serde_json::from_str(&lone_halves_replaced(line)).map_err(|_| e)
301        }
302        other => other,
303    }
304}
305
306/// `json` with each escaped half of a UTF-16 pair that has no other half written `�`.
307fn lone_halves_replaced(json: &str) -> String {
308    let half = |s: &str| -> Option<u16> {
309        let hex = s.strip_prefix("\\u").or_else(|| s.strip_prefix("\\U"))?;
310        u16::from_str_radix(hex.get(..4)?, 16)
311            .ok()
312            .filter(|c| (0xd800..=0xdfff).contains(c))
313    };
314    let mut out = String::with_capacity(json.len());
315    let mut i = 0;
316    while i < json.len() {
317        let rest = &json[i..];
318        if !rest.starts_with('\\') {
319            let c = rest.chars().next().unwrap_or_default();
320            out.push(c);
321            i += c.len_utf8().max(1);
322            continue;
323        }
324        match half(rest) {
325            Some(0xd800..=0xdbff)
326                if half(&rest[6..]).is_some_and(|low| (0xdc00..=0xdfff).contains(&low)) =>
327            {
328                out.push_str(&rest[..12]);
329                i += 12;
330            }
331            Some(_) => {
332                out.push_str("\\ufffd");
333                i += 6;
334            }
335            // Any other escape, kept whole: `\\u…` is a backslash, then text.
336            None => {
337                let next = rest[1..].chars().next().map_or(0, char::len_utf8);
338                out.push_str(&rest[..1 + next]);
339                i += 1 + next;
340            }
341        }
342    }
343    out
344}
345
346#[cfg(test)]
347mod tests {
348    use super::*;
349
350    fn run(lines: &str) -> Vec<Value> {
351        let mut out = Vec::new();
352        serve(lines.as_bytes(), &mut out, json!({}), |m, p| match m {
353            "echo" => Ok(p),
354            other => Err(Refusal::not_found(other)),
355        })
356        .unwrap();
357        String::from_utf8(out)
358            .unwrap()
359            .lines()
360            .map(|l| serde_json::from_str(l).unwrap())
361            .collect()
362    }
363
364    #[test]
365    fn the_handshake_speaks_the_version_of_the_protocol() {
366        let out = run(r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}"#);
367        assert_eq!(out[0]["result"]["protocolVersion"], PROTOCOL);
368        assert_eq!(out[0]["id"], 1);
369    }
370
371    #[test]
372    fn it_answers_shutdown_then_reads_no_more() {
373        let out = run(concat!(
374            r#"{"jsonrpc":"2.0","id":1,"method":"shutdown"}"#,
375            "\n",
376            r#"{"jsonrpc":"2.0","id":2,"method":"echo","params":{"a":1}}"#
377        ));
378        assert_eq!(out.len(), 1);
379        assert_eq!(out[0]["result"], Value::Null);
380    }
381
382    #[test]
383    fn a_method_unknown_is_refused_and_a_notification_is_not_answered() {
384        let out = run(concat!(
385            r#"{"jsonrpc":"2.0","id":1,"method":"nope"}"#,
386            "\n",
387            r#"{"jsonrpc":"2.0","method":"echo","params":{}}"#,
388            "\n",
389            r#"{"jsonrpc":"2.0","id":3,"method":"echo","params":{"a":1}}"#
390        ));
391        assert_eq!(out.len(), 2);
392        assert_eq!(out[0]["error"]["code"], code::METHOD_NOT_FOUND);
393        assert_eq!(out[1]["result"]["a"], 1);
394    }
395
396    #[test]
397    fn garbage_is_refused_with_a_parse_error() {
398        let out = run("not json");
399        assert_eq!(out[0]["error"]["code"], code::PARSE);
400    }
401
402    #[test]
403    fn the_hello_is_written_as_the_protocol_says() {
404        let hello = Hello::new("2.2.0", "dev.escouade.minimal", "2.2.0", json!({}));
405        let v = serde_json::to_value(hello).unwrap();
406        assert_eq!(v["protocolVersion"], 1);
407        assert_eq!(v["escouade"]["version"], "2.2.0");
408        assert_eq!(v["plugin"]["id"], "dev.escouade.minimal");
409        assert!(v.get("lang").is_none(), "{v}");
410    }
411
412    #[test]
413    fn a_language_comes_with_the_hello_and_an_older_hello_still_reads() {
414        let mut hello = Hello::new("2.2.0", "dev.escouade.minimal", "2.2.0", json!({}));
415        hello.lang = Some("en".into());
416        let v = serde_json::to_value(&hello).unwrap();
417        assert_eq!(v["lang"], "en");
418        assert_eq!(serde_json::from_value::<Hello>(v).unwrap(), hello);
419        hello.lang = None;
420        let v = serde_json::to_value(&hello).unwrap();
421        assert_eq!(serde_json::from_value::<Hello>(v).unwrap(), hello);
422    }
423
424    #[test]
425    fn what_escouade_says_of_itself_is_read_from_the_params_of_initialize() {
426        let params = json!({ "lang": "en", "escouade": { "version": "2.2.0" } });
427        let said = Greeting::of(&params);
428        assert_eq!(
429            (said.lang.as_deref(), said.version.as_str()),
430            (Some("en"), "2.2.0")
431        );
432        assert_eq!(Greeting::of(&json!({})), Greeting::default());
433    }
434}