返回 CodeWhale
lsp_hooks.rs
根目录 / crates / tui / src / core / engine / lsp_hooks.rs
1 //! Post-edit LSP diagnostics hooks for engine tool execution.
2 //!
3 //! The turn loop only needs to ask "did a successful edit produce diagnostics?"
4 //! This module owns the tool-input path extraction and the synthetic diagnostic
5 //! message injection so the top-level engine module stays focused on session
6 //! orchestration.
7
8 use std::path::PathBuf;
9
10 use crate::tools::apply_patch::preflight_apply_patch;
11
12 use super::*;
13
14 /// #136: derive the file path(s) edited by a tool call. Returns the empty
15 /// vec for tools that don't modify files. We intentionally only handle the
16 /// three known edit tools — adding more (e.g. specialized refactor tools)
17 /// is a one-line change here.
18 pub(super) fn edited_paths_for_tool(tool_name: &str, input: &serde_json::Value) -> Vec<PathBuf> {
19 let semantic = crate::tools::canonical_action::canonical_action_alias(tool_name, input);
20 match semantic {
21 "edit_file" | "write_file" => {
22 if let Some(path) = input.get("path").and_then(|v| v.as_str()) {
23 vec![PathBuf::from(path)]
24 } else {
25 Vec::new()
26 }
27 }
28 // A section that deletes its file (`+++ /dev/null`) leaves nothing
29 // to diagnose, so only files that still exist afterwards are listed.
30 "apply_patch" => preflight_apply_patch(input)
31 .map(|preflight| {
32 preflight
33 .touched_files
34 .into_iter()
35 .filter(|path| !preflight.deletes.contains(path))
36 .map(PathBuf::from)
37 .collect()
38 })
39 .unwrap_or_default(),
40 _ => Vec::new(),
41 }
42 }
43
44 impl Engine {
45 /// #136: post-edit hook. Inspects the tool name + input, derives the
46 /// edited file path, and asks the LSP manager for diagnostics. The
47 /// rendered block is queued in `pending_lsp_blocks` and flushed to the
48 /// session message stream just before the next API request. Failure is
49 /// silent by design — a missing/crashing LSP server must never block
50 /// the agent.
51 pub(super) async fn run_post_edit_lsp_hook(
52 &mut self,
53 tool_name: &str,
54 tool_input: &serde_json::Value,
55 ) {
56 if !self.lsp_manager.config().enabled {
57 return;
58 }
59 let paths = edited_paths_for_tool(tool_name, tool_input);
60 let mut found = 0usize;
61 let mut files = 0usize;
62 for path in paths {
63 let absolute = if path.is_absolute() {
64 path.clone()
65 } else {
66 self.session.workspace.join(&path)
67 };
68 // Use a short edit-sequence based on the existing turn counter so
69 // log output stays correlated even though we do not currently
70 // batch by sequence.
71 let seq = self.turn_counter;
72 if let Some(block) = self.lsp_manager.diagnostics_for(&absolute, seq).await {
73 found = found.saturating_add(block.items.len());
74 files = files.saturating_add(1);
75 self.pending_lsp_blocks.push(block);
76 }
77 }
78 if found > 0 {
79 let _ = self
80 .send_event(Event::LspRepairUpdate {
81 diagnostics_found: found,
82 files,
83 injected: false,
84 })
85 .await;
86 }
87 }
88
89 /// Drain `pending_lsp_blocks` into a single synthetic user message so the
90 /// model sees the diagnostics on its next request. Skips when nothing is
91 /// pending. The message uses the standard `text` content block shape
92 /// (the same shape as the post-tool steer messages) so we don't need to
93 /// invent a new envelope.
94 pub(super) async fn flush_pending_lsp_diagnostics(&mut self) {
95 if self.pending_lsp_blocks.is_empty() {
96 return;
97 }
98 let blocks = std::mem::take(&mut self.pending_lsp_blocks);
99 let found: usize = blocks.iter().map(|b| b.items.len()).sum();
100 let files = blocks.len();
101 let rendered = crate::lsp::render_blocks(&blocks);
102 if rendered.is_empty() {
103 return;
104 }
105 self.add_session_message(self.runtime_text_message_with_turn_metadata(
106 rendered,
107 crate::core::ops::UserInputProvenance::Runtime,
108 ))
109 .await;
110 let _ = self
111 .send_event(Event::LspRepairUpdate {
112 diagnostics_found: found,
113 files,
114 injected: true,
115 })
116 .await;
117 }
118 }
119
120 #[cfg(test)]
121 mod primitive_name_tests {
122 use super::*;
123 use serde_json::json;
124
125 #[test]
126 fn lowercase_file_mutations_reach_the_lsp_hook() {
127 for name in ["write", "edit", "write_file", "edit_file"] {
128 assert_eq!(
129 edited_paths_for_tool(name, &json!({"path": "src/lib.rs"})),
130 vec![PathBuf::from("src/lib.rs")]
131 );
132 }
133 }
134 }
135
135 lines RUST