hindsight_copilot/
lsp.rs

1// Copyright (c) 2026 - present Nicholas D. Crosbie
2// SPDX-License-Identifier: MIT
3
4//! LSP types and parsing for Copilot logs
5//!
6//! GitHub Copilot operates as a Language Server, so its log data follows
7//! the Language Server Protocol (LSP) format.
8
9use lsp_types::{Position, Range};
10use serde::{Deserialize, Serialize};
11
12/// Represents an LSP-style message from Copilot logs
13#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
14pub struct LspMessage {
15    /// JSON-RPC version
16    pub jsonrpc: String,
17    /// Message ID (for request/response correlation)
18    #[serde(skip_serializing_if = "Option::is_none")]
19    pub id: Option<serde_json::Value>,
20    /// Method name
21    #[serde(skip_serializing_if = "Option::is_none")]
22    pub method: Option<String>,
23    /// Parameters
24    #[serde(skip_serializing_if = "Option::is_none")]
25    pub params: Option<serde_json::Value>,
26    /// Result (for responses)
27    #[serde(skip_serializing_if = "Option::is_none")]
28    pub result: Option<serde_json::Value>,
29    /// Error (for error responses)
30    #[serde(skip_serializing_if = "Option::is_none")]
31    pub error: Option<serde_json::Value>,
32}
33
34impl LspMessage {
35    /// Create a new request message
36    #[must_use]
37    pub fn request(id: impl Into<serde_json::Value>, method: &str) -> Self {
38        Self {
39            jsonrpc: "2.0".to_string(),
40            id: Some(id.into()),
41            method: Some(method.to_string()),
42            params: None,
43            result: None,
44            error: None,
45        }
46    }
47
48    /// Create a new notification message (no id)
49    #[must_use]
50    pub fn notification(method: &str) -> Self {
51        Self {
52            jsonrpc: "2.0".to_string(),
53            id: None,
54            method: Some(method.to_string()),
55            params: None,
56            result: None,
57            error: None,
58        }
59    }
60
61    /// Check if this is a request (has id and method)
62    #[must_use]
63    pub fn is_request(&self) -> bool {
64        self.id.is_some() && self.method.is_some()
65    }
66
67    /// Check if this is a response (has id and result or error)
68    #[must_use]
69    pub fn is_response(&self) -> bool {
70        self.id.is_some() && (self.result.is_some() || self.error.is_some())
71    }
72
73    /// Check if this is a notification (has method but no id)
74    #[must_use]
75    pub fn is_notification(&self) -> bool {
76        self.id.is_none() && self.method.is_some()
77    }
78
79    /// Check if this is an error response
80    #[must_use]
81    pub fn is_error(&self) -> bool {
82        self.error.is_some()
83    }
84
85    /// Add parameters to the message
86    #[must_use]
87    pub fn with_params(mut self, params: serde_json::Value) -> Self {
88        self.params = Some(params);
89        self
90    }
91}
92
93/// Code context sent to Copilot
94#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
95pub struct CodeContext {
96    /// The text document URI
97    pub uri: String,
98    /// Position in the document
99    pub position: Position,
100    /// Visible range in the editor
101    #[serde(skip_serializing_if = "Option::is_none")]
102    pub visible_range: Option<Range>,
103}
104
105impl CodeContext {
106    /// Create a new code context
107    #[must_use]
108    pub fn new(uri: String, line: u32, character: u32) -> Self {
109        Self {
110            uri,
111            position: Position { line, character },
112            visible_range: None,
113        }
114    }
115
116    /// Set the visible range
117    #[must_use]
118    pub fn with_visible_range(mut self, start: Position, end: Position) -> Self {
119        self.visible_range = Some(Range { start, end });
120        self
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127    use similar_asserts::assert_eq;
128
129    #[test]
130    fn test_lsp_message_serialization_roundtrip() {
131        let msg = LspMessage::request(1, "textDocument/completion")
132            .with_params(serde_json::json!({"textDocument": {"uri": "file:///test.rs"}}));
133
134        let json = serde_json::to_string(&msg).expect("serialize");
135        let deserialized: LspMessage = serde_json::from_str(&json).expect("deserialize");
136        assert_eq!(msg, deserialized);
137    }
138
139    #[test]
140    fn test_lsp_message_request() {
141        let msg = LspMessage::request(42, "test/method");
142
143        assert!(msg.is_request());
144        assert!(!msg.is_response());
145        assert!(!msg.is_notification());
146        assert_eq!(msg.jsonrpc, "2.0");
147        assert_eq!(msg.method, Some("test/method".to_string()));
148    }
149
150    #[test]
151    fn test_lsp_message_notification() {
152        let msg = LspMessage::notification("window/logMessage");
153
154        assert!(msg.is_notification());
155        assert!(!msg.is_request());
156        assert!(!msg.is_response());
157        assert!(msg.id.is_none());
158    }
159
160    #[test]
161    fn test_lsp_message_response() {
162        let msg = LspMessage {
163            jsonrpc: "2.0".to_string(),
164            id: Some(serde_json::json!(1)),
165            method: None,
166            params: None,
167            result: Some(serde_json::json!({"completions": []})),
168            error: None,
169        };
170
171        assert!(msg.is_response());
172        assert!(!msg.is_request());
173        assert!(!msg.is_error());
174    }
175
176    #[test]
177    fn test_lsp_message_error_response() {
178        let msg = LspMessage {
179            jsonrpc: "2.0".to_string(),
180            id: Some(serde_json::json!(1)),
181            method: None,
182            params: None,
183            result: None,
184            error: Some(serde_json::json!({"code": -32600, "message": "Invalid Request"})),
185        };
186
187        assert!(msg.is_response());
188        assert!(msg.is_error());
189    }
190
191    #[test]
192    fn test_lsp_message_skips_none_fields() {
193        let msg = LspMessage::notification("test");
194        let json = serde_json::to_string(&msg).expect("serialize");
195
196        // None fields should be omitted
197        assert!(!json.contains("\"id\""));
198        assert!(!json.contains("\"params\""));
199        assert!(!json.contains("\"result\""));
200        assert!(!json.contains("\"error\""));
201    }
202
203    #[test]
204    fn test_code_context_serialization_roundtrip() {
205        let ctx = CodeContext::new("file:///test.rs".to_string(), 10, 5);
206
207        let json = serde_json::to_string(&ctx).expect("serialize");
208        let deserialized: CodeContext = serde_json::from_str(&json).expect("deserialize");
209        assert_eq!(ctx, deserialized);
210    }
211
212    #[test]
213    fn test_code_context_with_visible_range() {
214        let ctx = CodeContext::new("file:///test.rs".to_string(), 10, 5).with_visible_range(
215            Position {
216                line: 0,
217                character: 0,
218            },
219            Position {
220                line: 50,
221                character: 0,
222            },
223        );
224
225        assert!(ctx.visible_range.is_some());
226        let range = ctx.visible_range.unwrap();
227        assert_eq!(range.start.line, 0);
228        assert_eq!(range.end.line, 50);
229    }
230
231    #[test]
232    fn test_code_context_skips_none_visible_range() {
233        let ctx = CodeContext::new("file:///test.rs".to_string(), 10, 5);
234        let json = serde_json::to_string(&ctx).expect("serialize");
235
236        assert!(!json.contains("visible_range"));
237    }
238}