Skip to main content

lattice_ai/mcp/
reads.rs

1//! IDE-protocol I2.2: the five read tools.
2//!
3//! Crate-owned logic. Each tool fetches Rust-typed data from the
4//! protocol-neutral [`lattice_agent::EditorAccess`] port (open-buffer set +
5//! active selection, on-demand text / dirty via the generic `BufferStore`)
6//! plus, for `getDiagnostics` only, the generic `DiagnosticsQuery`
7//! (lattice-lsp) — and wraps it into an MCP result `Value`. No host
8//! claude-code trait is involved.
9//!
10//! Shape: pure *result-builders* (`*_result`, unit-tested with plain data)
11//! plus thin tool entry points (`get_*` / `check_*`) that fetch from a
12//! [`ReadContext`] and call the builders. Reads run on the WS task off the
13//! editor thread — `BufferStore::handle_for(id)` → `Document` snapshot reads
14//! are wait-free `ArcSwap` loads, and the port's cache is a brief `Mutex`.
15//!
16//! Result JSON shapes are PROVISIONAL (like the lockfile schema) until
17//! validated against a live `claude` CLI in the I0–I2 walking skeleton.
18
19use std::path::Path;
20
21use serde_json::{Value, json};
22
23use lattice_agent::EditorAccess;
24use lattice_agent::editor_access::{ordered, path_to_uri};
25use lattice_protocol::{Position, Selection};
26
27/// The generic services the read tools consume. Built once at server spawn
28/// from boot-provided handles and held behind an `Arc` in the dispatch
29/// context. Any field may be absent (headless / test harness), in which
30/// case the dependent tool degrades to an empty result, never an error.
31#[derive(Clone)]
32pub struct ReadContext {
33    /// The protocol-neutral editor-read port (open set + active selection +
34    /// on-demand text / dirty).
35    pub editor: EditorAccess,
36    /// Generic diagnostics query (lattice-lsp) for `getDiagnostics`.
37    pub diagnostics: Option<lattice_lsp::modes::DiagnosticsQueryHandle>,
38}
39
40/// `character` carries the byte offset within the line — lattice's
41/// `Position` is byte-based. PROVISIONAL vs the VS Code UTF-16 contract.
42fn pos_json(p: Position) -> Value {
43    json!({ "line": p.line, "character": p.byte })
44}
45
46// ---- pure result builders -------------------------------------------------
47
48/// `getCurrentSelection` result. `None` selection → an empty result (the
49/// agent's "nothing selected" case), not an error.
50pub fn selection_result(
51    file_path: Option<&Path>,
52    selected_text: &str,
53    selection: Option<&Selection>,
54) -> Value {
55    let Some(sel) = selection else {
56        return json!({ "text": "", "filePath": Value::Null, "selection": Value::Null });
57    };
58    let (start, end) = ordered(sel);
59    json!({
60        "text": selected_text,
61        "filePath": file_path.map(|p| p.display().to_string()),
62        "fileUrl": file_path.map(path_to_uri),
63        "selection": {
64            "start": pos_json(start),
65            "end": pos_json(end),
66            "isEmpty": sel.is_cursor(),
67        }
68    })
69}
70
71/// `getOpenEditors` result from `(filePath, isActive)` rows (file buffers
72/// only; synthetic / unsaved buffers are excluded by the caller).
73pub fn open_editors_result(editors: &[(String, bool)]) -> Value {
74    let tabs: Vec<Value> = editors
75        .iter()
76        .map(|(path, active)| {
77            json!({
78                "filePath": path,
79                "uri": format!("file://{path}"),
80                "isActive": active,
81            })
82        })
83        .collect();
84    json!({ "editors": tabs })
85}
86
87/// `getWorkspaceFolders` result.
88pub fn workspace_folders_result(folders: &[String]) -> Value {
89    let list: Vec<Value> = folders
90        .iter()
91        .map(|f| {
92            json!({
93                "path": f,
94                "uri": format!("file://{f}"),
95                "name": Path::new(f).file_name().and_then(|n| n.to_str()).unwrap_or(f),
96            })
97        })
98        .collect();
99    json!({ "folders": list })
100}
101
102/// `getDiagnostics` result. Each `(uri, diagnostics)` group serializes its
103/// diagnostics in LSP wire shape (`lsp_types::Diagnostic` is `Serialize`).
104pub fn diagnostics_result(files: &[(String, Vec<lattice_lsp::Diagnostic>)]) -> Value {
105    let out: Vec<Value> = files
106        .iter()
107        .map(|(uri, diags)| {
108            json!({
109                "uri": uri,
110                "diagnostics": serde_json::to_value(diags).unwrap_or_else(|_| json!([])),
111            })
112        })
113        .collect();
114    json!({ "diagnostics": out })
115}
116
117/// `checkDocumentDirty` result.
118pub fn dirty_result(is_dirty: bool) -> Value {
119    json!({ "isDirty": is_dirty })
120}
121
122// ---- tool entry points ----------------------------------------------------
123
124/// `getCurrentSelection`: the active buffer's path + selection (+ selected
125/// text via the buffer store, when available).
126pub fn get_current_selection(ctx: &ReadContext) -> Value {
127    match ctx.editor.current_selection() {
128        Some(sel) => selection_result(
129            sel.file_path.as_deref(),
130            &sel.selected_text,
131            sel.selection.as_ref(),
132        ),
133        None => selection_result(None, "", None),
134    }
135}
136
137/// `getOpenEditors`: the open file-editor buffers (skips synthetic / unsaved
138/// buffers with no path).
139pub fn get_open_editors(ctx: &ReadContext) -> Value {
140    let editors: Vec<(String, bool)> = ctx
141        .editor
142        .open_editors()
143        .into_iter()
144        .map(|e| (e.path, e.is_active))
145        .collect();
146    open_editors_result(&editors)
147}
148
149/// `getWorkspaceFolders`: from the server config.
150pub fn get_workspace_folders(ctx: &ReadContext) -> Value {
151    workspace_folders_result(ctx.editor.workspace_folders())
152}
153
154/// `getDiagnostics`: all diagnostics, or just the requested `uri` argument.
155pub fn get_diagnostics(ctx: &ReadContext, arguments: &Value) -> Value {
156    let Some(dq) = ctx.diagnostics.as_ref() else {
157        return diagnostics_result(&[]);
158    };
159    let files: Vec<(String, Vec<lattice_lsp::Diagnostic>)> =
160        match arguments.get("uri").and_then(|v| v.as_str()) {
161            Some(uri) => vec![(uri.to_string(), dq.for_uri(uri))],
162            None => dq
163                .uris_with_diagnostics()
164                .into_iter()
165                .map(|uri| {
166                    let diags = dq.for_uri(&uri);
167                    (uri, diags)
168                })
169                .collect(),
170        };
171    diagnostics_result(&files)
172}
173
174/// `checkDocumentDirty`: dirty flag for the `filePath` argument. Unknown /
175/// absent path → `isDirty: false` (not an error).
176pub fn check_document_dirty(ctx: &ReadContext, arguments: &Value) -> Value {
177    let Some(path) = arguments.get("filePath").and_then(|v| v.as_str()) else {
178        return dirty_result(false);
179    };
180    dirty_result(ctx.editor.document_dirty(path))
181}
182
183#[cfg(test)]
184mod tests {
185    #![allow(clippy::unwrap_used)]
186    use super::*;
187    use lattice_agent::EditorStateCache;
188    use lattice_protocol::Event;
189    use lattice_protocol::ids::DocumentId;
190    use std::path::PathBuf;
191    use std::sync::{Arc, Mutex};
192
193    fn ctx_with(state: EditorStateCache, workspace: Vec<String>) -> ReadContext {
194        ReadContext {
195            editor: EditorAccess::new(Arc::new(Mutex::new(state)), None, workspace, None),
196            diagnostics: None,
197        }
198    }
199
200    #[test]
201    fn selection_result_none_is_empty_not_error() {
202        let v = selection_result(None, "", None);
203        assert_eq!(v["text"], "");
204        assert!(v["selection"].is_null());
205    }
206
207    #[test]
208    fn selection_result_orders_start_before_end() {
209        let sel = Selection {
210            anchor: Position::new(5, 2),
211            head: Position::new(1, 0),
212            visual: None,
213        };
214        let v = selection_result(Some(Path::new("/a.rs")), "x", Some(&sel));
215        assert_eq!(v["selection"]["start"]["line"], 1);
216        assert_eq!(v["selection"]["end"]["line"], 5);
217        assert_eq!(v["filePath"], "/a.rs");
218        assert_eq!(v["fileUrl"], "file:///a.rs");
219    }
220
221    #[test]
222    fn open_editors_lists_only_file_buffers_and_marks_active() {
223        let mut s = EditorStateCache::default();
224        s.apply_event(&Event::DocumentOpened {
225            id: DocumentId::new(1),
226            path: Some(PathBuf::from("/a.rs")),
227            version: 1,
228            text: String::new(),
229        });
230        s.apply_event(&Event::DocumentOpened {
231            id: DocumentId::new(2),
232            path: None,
233            version: 1,
234            text: String::new(),
235        });
236        s.apply_event(&Event::SelectionsChanged {
237            id: DocumentId::new(1),
238            version: 2,
239            selections: lattice_protocol::SelectionSet::default(),
240        });
241        let v = get_open_editors(&ctx_with(s, vec![]));
242        let editors = v["editors"].as_array().unwrap();
243        assert_eq!(editors.len(), 1, "synthetic (no-path) buffer excluded");
244        assert_eq!(editors[0]["filePath"], "/a.rs");
245        assert_eq!(editors[0]["isActive"], true);
246    }
247
248    #[test]
249    fn workspace_folders_from_config() {
250        let v = get_workspace_folders(&ctx_with(
251            EditorStateCache::default(),
252            vec!["/work/project".to_string()],
253        ));
254        let folders = v["folders"].as_array().unwrap();
255        assert_eq!(folders.len(), 1);
256        assert_eq!(folders[0]["path"], "/work/project");
257        assert_eq!(folders[0]["name"], "project");
258    }
259
260    #[test]
261    fn diagnostics_with_no_service_is_empty_not_error() {
262        let v = get_diagnostics(&ctx_with(EditorStateCache::default(), vec![]), &json!({}));
263        assert_eq!(v["diagnostics"].as_array().map(|a| a.len()), Some(0));
264    }
265
266    #[test]
267    fn check_dirty_unknown_path_is_false() {
268        let v = check_document_dirty(
269            &ctx_with(EditorStateCache::default(), vec![]),
270            &json!({ "filePath": "/nope.rs" }),
271        );
272        assert_eq!(v["isDirty"], false);
273    }
274
275    #[test]
276    fn get_current_selection_no_active_is_empty() {
277        let v = get_current_selection(&ctx_with(EditorStateCache::default(), vec![]));
278        assert_eq!(v["text"], "");
279        assert!(v["selection"].is_null());
280    }
281}