Skip to main content

logicaffeine_cli/dap/
mod.rs

1//! `largo dap` — the LOGOS debugger served over the Debug Adapter Protocol.
2//!
3//! DAP is JSON over stdio with `Content-Length` framing (like LSP). The entire
4//! protocol lives in the pure [`DapServer::handle`] transition — one request
5//! object in, a response plus any events back — so every conformance test drives
6//! it in-process (`dap_lock`); [`cmd_dap`] is only the stdio shell around it.
7//!
8//! The server is a thin serializer over
9//! [`logicaffeine_compile::debug_session::DebugSession`] — the SAME facade the
10//! Studio drawer renders (session_parity_lock proves they agree), so an editor
11//! stepping through LOGOS over DAP and the in-browser drawer observe one truth.
12//! The headline is real reverse execution: `supportsStepBack` is advertised and
13//! `stepBack`/`reverseContinue` are honest time-travel (the debugger keeps the
14//! whole snapshot history), not a re-run illusion.
15
16use serde_json::{json, Value};
17
18use logicaffeine_compile::debug_session::DebugSession;
19
20/// The DAP server state machine: an optional live session and a monotone output
21/// sequence counter (DAP stamps every message the adapter sends with a `seq`).
22pub struct DapServer {
23    session: Option<DebugSession>,
24    out_seq: i64,
25    /// Set once `disconnect`/`terminate` is seen — the stdio shell then stops.
26    done: bool,
27}
28
29impl Default for DapServer {
30    fn default() -> Self {
31        Self::new()
32    }
33}
34
35impl DapServer {
36    /// A fresh server with no session yet (created on `launch`).
37    pub fn new() -> DapServer {
38        DapServer {
39            session: None,
40            out_seq: 0,
41            done: false,
42        }
43    }
44
45    /// Whether the peer has asked to end the session.
46    pub fn is_done(&self) -> bool {
47        self.done
48    }
49
50    /// The next outbound `seq` (DAP requires every adapter message to carry one).
51    fn next_seq(&mut self) -> i64 {
52        self.out_seq += 1;
53        self.out_seq
54    }
55
56    /// Build a response to `request` (echoing its `seq` as `request_seq`).
57    fn response(&mut self, request: &Value, success: bool, body: Value) -> Value {
58        let request_seq = request.get("seq").and_then(Value::as_i64).unwrap_or(0);
59        let command = request.get("command").and_then(Value::as_str).unwrap_or("");
60        json!({
61            "seq": self.next_seq(),
62            "type": "response",
63            "request_seq": request_seq,
64            "success": success,
65            "command": command,
66            "body": body,
67        })
68    }
69
70    /// Build a response carrying an error `message` (DAP `success: false`).
71    fn error(&mut self, request: &Value, message: &str) -> Value {
72        let request_seq = request.get("seq").and_then(Value::as_i64).unwrap_or(0);
73        let command = request.get("command").and_then(Value::as_str).unwrap_or("");
74        json!({
75            "seq": self.next_seq(),
76            "type": "response",
77            "request_seq": request_seq,
78            "success": false,
79            "command": command,
80            "message": message,
81        })
82    }
83
84    /// Build an event.
85    fn event(&mut self, name: &str, body: Value) -> Value {
86        json!({
87            "seq": self.next_seq(),
88            "type": "event",
89            "event": name,
90            "body": body,
91        })
92    }
93
94    /// A `stopped` event (after a step / breakpoint) or a `terminated` event when
95    /// the program has run to its end — whichever the session's state calls for.
96    fn stop_or_terminate(&mut self, reason: &str) -> Value {
97        let running = self
98            .session
99            .as_ref()
100            .map(DebugSession::is_running)
101            .unwrap_or(false);
102        if running {
103            self.event(
104                "stopped",
105                json!({ "reason": reason, "threadId": 1, "allThreadsStopped": true }),
106            )
107        } else {
108            self.event("terminated", json!({}))
109        }
110    }
111
112    /// Handle one DAP request, returning the response followed by any events (in
113    /// send order). Unknown commands get a `success: false` response, never a panic.
114    pub fn handle(&mut self, request: &Value) -> Vec<Value> {
115        let command = request.get("command").and_then(Value::as_str).unwrap_or("");
116        match command {
117            "initialize" => {
118                let resp = self.response(
119                    request,
120                    true,
121                    json!({
122                        "supportsStepBack": true,
123                        "supportsConfigurationDoneRequest": true,
124                        "supportsEvaluateForHovers": true,
125                        "supportsTerminateRequest": true,
126                    }),
127                );
128                let initialized = self.event("initialized", json!({}));
129                vec![resp, initialized]
130            }
131
132            "launch" => {
133                let args = request.get("arguments").cloned().unwrap_or(Value::Null);
134                let source = launch_source(&args);
135                match source {
136                    Err(msg) => vec![self.error(request, &msg)],
137                    Ok(src) => match DebugSession::launch(&src) {
138                        Err(e) => vec![self.error(request, &e)],
139                        Ok(session) => {
140                            self.session = Some(session);
141                            let resp = self.response(request, true, json!({}));
142                            let stopped = self.event(
143                                "stopped",
144                                json!({ "reason": "entry", "threadId": 1, "allThreadsStopped": true }),
145                            );
146                            vec![resp, stopped]
147                        }
148                    },
149                }
150            }
151
152            "setBreakpoints" => {
153                // Op-address breakpoints: DAP source `line`s are echoed back as
154                // verified so a client can set them; `continue` honours whatever
155                // the facade has armed. (Line→pc mapping is the editor bridge's
156                // job, tracked with the VSCode contribution.)
157                let lines = request
158                    .get("arguments")
159                    .and_then(|a| a.get("breakpoints"))
160                    .and_then(Value::as_array)
161                    .cloned()
162                    .unwrap_or_default();
163                let verified: Vec<Value> = lines
164                    .iter()
165                    .map(|bp| {
166                        let line = bp.get("line").and_then(Value::as_i64).unwrap_or(0);
167                        json!({ "verified": true, "line": line })
168                    })
169                    .collect();
170                vec![self.response(request, true, json!({ "breakpoints": verified }))]
171            }
172
173            "configurationDone" => vec![self.response(request, true, json!({}))],
174
175            "threads" => {
176                let threads = match &self.session {
177                    None => Vec::new(),
178                    Some(s) => s
179                        .threads()
180                        .into_iter()
181                        .map(|t| json!({ "id": t.id, "name": t.name }))
182                        .collect(),
183                };
184                vec![self.response(request, true, json!({ "threads": threads }))]
185            }
186
187            "stackTrace" => {
188                let frames = match &self.session {
189                    None => Vec::new(),
190                    Some(s) => s
191                        .stack_trace()
192                        .into_iter()
193                        .map(|f| json!({ "id": f.id, "name": f.name, "line": f.line, "column": 0 }))
194                        .collect::<Vec<_>>(),
195                };
196                let total = frames.len();
197                vec![self.response(
198                    request,
199                    true,
200                    json!({ "stackFrames": frames, "totalFrames": total }),
201                )]
202            }
203
204            "scopes" => {
205                // One Locals scope per frame; its variablesReference is frameId+1
206                // (reference 0 is reserved by DAP for "no children").
207                let frame_id = request
208                    .get("arguments")
209                    .and_then(|a| a.get("frameId"))
210                    .and_then(Value::as_i64)
211                    .unwrap_or(0);
212                vec![self.response(
213                    request,
214                    true,
215                    json!({ "scopes": [{
216                        "name": "Locals",
217                        "variablesReference": frame_id + 1,
218                        "expensive": false,
219                    }] }),
220                )]
221            }
222
223            "variables" => {
224                let vref = request
225                    .get("arguments")
226                    .and_then(|a| a.get("variablesReference"))
227                    .and_then(Value::as_i64)
228                    .unwrap_or(0);
229                let frame_id = vref - 1;
230                let vars = match (&self.session, frame_id >= 0) {
231                    (Some(s), true) => s
232                        .variables(frame_id)
233                        .into_iter()
234                        .map(|v| {
235                            json!({
236                                "name": v.name,
237                                "value": v.value,
238                                "type": v.kind,
239                                "variablesReference": 0,
240                            })
241                        })
242                        .collect::<Vec<_>>(),
243                    _ => Vec::new(),
244                };
245                vec![self.response(request, true, json!({ "variables": vars }))]
246            }
247
248            "next" | "stepIn" | "stepOut" | "continue" | "stepBack" | "reverseContinue" => {
249                if self.session.is_none() {
250                    return vec![self.error(request, "no active session — send launch first")];
251                }
252                {
253                    let s = self.session.as_mut().unwrap();
254                    match command {
255                        "next" => s.next(),
256                        "stepIn" => s.step_in(),
257                        "stepOut" => s.step_out(),
258                        "continue" => s.continue_(),
259                        "stepBack" => s.step_back(),
260                        "reverseContinue" => s.reverse_continue(),
261                        _ => unreachable!("outer match guarantees a stepping command"),
262                    }
263                }
264                let body = if command == "continue" {
265                    json!({ "allThreadsContinued": true })
266                } else {
267                    json!({})
268                };
269                let resp = self.response(request, true, body);
270                let evt = self.stop_or_terminate("step");
271                vec![resp, evt]
272            }
273
274            "evaluate" => {
275                let expr = request
276                    .get("arguments")
277                    .and_then(|a| a.get("expression"))
278                    .and_then(Value::as_str)
279                    .unwrap_or("");
280                match &self.session {
281                    None => vec![self.error(request, "no active session")],
282                    Some(s) => match s.evaluate(expr) {
283                        Ok(result) => vec![self.response(
284                            request,
285                            true,
286                            json!({ "result": result.trim_end(), "variablesReference": 0 }),
287                        )],
288                        Err(e) => vec![self.error(request, &e)],
289                    },
290                }
291            }
292
293            "disconnect" | "terminate" => {
294                self.done = true;
295                vec![self.response(request, true, json!({}))]
296            }
297
298            other => vec![self.error(request, &format!("unsupported DAP command '{other}'"))],
299        }
300    }
301}
302
303/// Resolve the LOGOS source for a `launch` request: an inline `source` string,
304/// or a `program` file path to read. One of the two must be present.
305fn launch_source(args: &Value) -> Result<String, String> {
306    if let Some(src) = args.get("source").and_then(Value::as_str) {
307        return Ok(src.to_string());
308    }
309    if let Some(path) = args.get("program").and_then(Value::as_str) {
310        return std::fs::read_to_string(path)
311            .map_err(|e| format!("cannot read program '{path}': {e}"));
312    }
313    Err("launch needs a `source` (inline LOGOS) or a `program` (file path)".to_string())
314}
315
316/// `largo dap` — speak DAP over stdio with `Content-Length` framing until the
317/// peer disconnects. Stdout carries nothing but framed DAP messages.
318pub fn cmd_dap() -> Result<(), Box<dyn std::error::Error>> {
319    use std::io::Write;
320
321    let stdin = std::io::stdin();
322    let stdout = std::io::stdout();
323    let mut server = DapServer::new();
324    let mut reader = std::io::BufReader::new(stdin.lock());
325
326    while let Some(request) = read_message(&mut reader)? {
327        let outs = server.handle(&request);
328        let mut out = stdout.lock();
329        for msg in outs {
330            write_message(&mut out, &msg)?;
331        }
332        out.flush()?;
333        if server.is_done() {
334            break;
335        }
336    }
337    Ok(())
338}
339
340/// Read one `Content-Length`-framed JSON message from `reader`, or `None` at EOF.
341fn read_message<R: std::io::BufRead>(reader: &mut R) -> std::io::Result<Option<Value>> {
342    let mut content_length: Option<usize> = None;
343    loop {
344        let mut line = String::new();
345        let n = reader.read_line(&mut line)?;
346        if n == 0 {
347            return Ok(None); // EOF
348        }
349        let trimmed = line.trim_end_matches(['\r', '\n']);
350        if trimmed.is_empty() {
351            break; // end of headers
352        }
353        if let Some(rest) = trimmed.strip_prefix("Content-Length:") {
354            content_length = rest.trim().parse().ok();
355        }
356    }
357    let len = match content_length {
358        Some(l) => l,
359        None => return Ok(None),
360    };
361    let mut buf = vec![0u8; len];
362    reader.read_exact(&mut buf)?;
363    match serde_json::from_slice(&buf) {
364        Ok(v) => Ok(Some(v)),
365        Err(_) => Ok(Some(Value::Null)),
366    }
367}
368
369/// Write one `Content-Length`-framed JSON message.
370fn write_message<W: std::io::Write>(out: &mut W, msg: &Value) -> std::io::Result<()> {
371    let body = serde_json::to_vec(msg)?;
372    write!(out, "Content-Length: {}\r\n\r\n", body.len())?;
373    out.write_all(&body)?;
374    Ok(())
375}