返回 DeepSeek-TUI-2026
diagnostics.rs
根目录 / crates / tui / src / lsp / diagnostics.rs
1 //! Diagnostic shape returned by the LSP transport, plus the renderer that
2 //! produces the `<diagnostics file="…">` block injected into the model
3 //! context after a file edit.
4 //!
5 //! Format (matches the spec given in issue #136):
6 //!
7 //! ```text
8 //! <diagnostics file="crates/tui/src/foo.rs">
9 //! ERROR [12:8] missing semicolon
10 //! ERROR [13:1] expected `,`, found `}`
11 //! </diagnostics>
12 //! ```
13 //!
14 //! Lines are 1-based. Columns are 1-based. We trim each diagnostic message
15 //! to a single line so the block stays compact.
16
17 use std::path::PathBuf;
18
19 /// Severity bucket used in the rendered block. Mirrors the LSP severity
20 /// codes (1 = Error, 2 = Warning, 3 = Information, 4 = Hint).
21 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
22 pub enum Severity {
23 Error,
24 Warning,
25 Information,
26 Hint,
27 }
28
29 impl Severity {
30 /// Decode the LSP integer severity. Returns `None` when the integer is
31 /// missing or unrecognized — callers default to `Error` to err on the
32 /// side of surfacing the issue.
33 #[must_use]
34 pub fn from_lsp(code: Option<i64>) -> Option<Self> {
35 match code? {
36 1 => Some(Severity::Error),
37 2 => Some(Severity::Warning),
38 3 => Some(Severity::Information),
39 4 => Some(Severity::Hint),
40 _ => None,
41 }
42 }
43
44 /// Uppercase label used in the rendered block.
45 #[must_use]
46 pub fn label(self) -> &'static str {
47 match self {
48 Severity::Error => "ERROR",
49 Severity::Warning => "WARNING",
50 Severity::Information => "INFO",
51 Severity::Hint => "HINT",
52 }
53 }
54 }
55
56 /// One LSP diagnostic, normalized to 1-based line/col so we can render it
57 /// directly. The transport layer is responsible for the `0-based -> 1-based`
58 /// conversion.
59 #[derive(Debug, Clone, PartialEq, Eq)]
60 pub struct Diagnostic {
61 pub line: u32,
62 pub column: u32,
63 pub severity: Severity,
64 pub message: String,
65 }
66
67 impl Diagnostic {
68 /// Trim the message to a single line for compact rendering.
69 fn render_message(&self) -> String {
70 let first_line = self.message.lines().next().unwrap_or("").trim();
71 first_line.to_string()
72 }
73 }
74
75 /// One file's worth of diagnostics, ready to render. The renderer caps the
76 /// list to `max_per_file` items.
77 #[derive(Debug, Clone)]
78 pub struct DiagnosticBlock {
79 /// Path used inside the `file="…"` attribute. Should be relative to the
80 /// workspace root when possible (we use `path.file_name()` if relativizing
81 /// fails, per the issue's hard rule).
82 pub file: PathBuf,
83 pub items: Vec<Diagnostic>,
84 }
85
86 impl DiagnosticBlock {
87 /// Render the block in the format pasted in the module docs. Returns the
88 /// empty string when `self.items` is empty so callers can `if !text.is_empty()`
89 /// before injecting.
90 #[must_use]
91 pub fn render(&self) -> String {
92 if self.items.is_empty() {
93 return String::new();
94 }
95 let file_attr = self.file.display();
96 let mut out = format!("<diagnostics file=\"{file_attr}\">\n");
97 for item in &self.items {
98 out.push_str(&format!(
99 " {} [{}:{}] {}\n",
100 item.severity.label(),
101 item.line,
102 item.column,
103 item.render_message(),
104 ));
105 }
106 out.push_str("</diagnostics>");
107 out
108 }
109
110 /// Truncate to at most `max_per_file` items, preserving order. The LSP
111 /// manager is responsible for sorting by severity before calling this so
112 /// errors are kept ahead of warnings when truncation happens.
113 pub fn truncate(&mut self, max_per_file: usize) {
114 if self.items.len() > max_per_file {
115 self.items.truncate(max_per_file);
116 }
117 }
118 }
119
120 /// Format a list of [`DiagnosticBlock`]s as a single bundle. Used by the
121 /// engine when one turn touched several files. Empty blocks are skipped.
122 #[must_use]
123 pub fn render_blocks(blocks: &[DiagnosticBlock]) -> String {
124 let mut chunks = Vec::new();
125 for block in blocks {
126 let rendered = block.render();
127 if !rendered.is_empty() {
128 chunks.push(rendered);
129 }
130 }
131 chunks.join("\n")
132 }
133
134 #[cfg(test)]
135 mod tests {
136 use super::*;
137
138 #[test]
139 fn severity_decodes_lsp_codes() {
140 assert_eq!(Severity::from_lsp(Some(1)), Some(Severity::Error));
141 assert_eq!(Severity::from_lsp(Some(2)), Some(Severity::Warning));
142 assert_eq!(Severity::from_lsp(Some(3)), Some(Severity::Information));
143 assert_eq!(Severity::from_lsp(Some(4)), Some(Severity::Hint));
144 assert_eq!(Severity::from_lsp(Some(99)), None);
145 assert_eq!(Severity::from_lsp(None), None);
146 }
147
148 #[test]
149 fn renders_block_in_required_format() {
150 let block = DiagnosticBlock {
151 file: PathBuf::from("crates/tui/src/foo.rs"),
152 items: vec![
153 Diagnostic {
154 line: 12,
155 column: 8,
156 severity: Severity::Error,
157 message: "missing semicolon".to_string(),
158 },
159 Diagnostic {
160 line: 13,
161 column: 1,
162 severity: Severity::Error,
163 message: "expected `,`, found `}`".to_string(),
164 },
165 ],
166 };
167 let rendered = block.render();
168 assert!(rendered.contains("<diagnostics file=\"crates/tui/src/foo.rs\">"));
169 assert!(rendered.contains("ERROR [12:8] missing semicolon"));
170 assert!(rendered.contains("ERROR [13:1] expected `,`, found `}`"));
171 assert!(rendered.ends_with("</diagnostics>"));
172 }
173
174 #[test]
175 fn empty_block_renders_to_empty_string() {
176 let block = DiagnosticBlock {
177 file: PathBuf::from("foo.rs"),
178 items: Vec::new(),
179 };
180 assert!(block.render().is_empty());
181 }
182
183 #[test]
184 fn truncate_caps_to_max() {
185 let mut block = DiagnosticBlock {
186 file: PathBuf::from("foo.rs"),
187 items: (0..30)
188 .map(|i| Diagnostic {
189 line: i,
190 column: 1,
191 severity: Severity::Error,
192 message: format!("err {i}"),
193 })
194 .collect(),
195 };
196 block.truncate(20);
197 assert_eq!(block.items.len(), 20);
198 }
199
200 #[test]
201 fn renders_only_first_line_of_message() {
202 let block = DiagnosticBlock {
203 file: PathBuf::from("foo.rs"),
204 items: vec![Diagnostic {
205 line: 1,
206 column: 1,
207 severity: Severity::Error,
208 message: "first line\nsecond line\nthird".to_string(),
209 }],
210 };
211 let rendered = block.render();
212 assert!(rendered.contains("first line"));
213 assert!(!rendered.contains("second line"));
214 assert!(!rendered.contains("third"));
215 }
216 }
217
217 lines RUST