| 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 |