Skip to main content

strop_lsp/
protocol.rs

1//! Shared request ownership, diagnostics and negotiated coordinate domains.
2use std::path::PathBuf;
3use strop_core::id::{BufferRevision, ByteColumn, DocumentId, LineIndex};
4use strop_workspace::ResourceLocation;
5
6/// Diagnostic severity (R13): a named domain, never a raw u8. Variant
7/// order matches the LSP rank, so `min_by_key` keeps the worst entry.
8#[derive(
9    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize,
10)]
11pub enum Severity {
12    Error,
13    Warning,
14    Information,
15    Hint,
16}
17
18impl Severity {
19    /// The LSP wire code (1=error … 4=hint) — display/trace boundary only.
20    pub const fn code(self) -> u8 {
21        match self {
22            Self::Error => 1,
23            Self::Warning => 2,
24            Self::Information => 3,
25            Self::Hint => 4,
26        }
27    }
28
29    /// Gutter/picker letter.
30    pub const fn char(self) -> char {
31        match self {
32            Self::Error => 'E',
33            Self::Warning => 'W',
34            Self::Information => 'I',
35            Self::Hint => 'H',
36        }
37    }
38}
39
40/// A text-document wire version on one connection. Monotonic across
41/// reopens so a stale versioned diagnostic can never relabel itself as
42/// belonging to a new document incarnation.
43#[derive(
44    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize,
45)]
46#[serde(transparent)]
47pub struct WireVersion(i32);
48
49impl WireVersion {
50    pub const fn new(value: i32) -> Self {
51        Self(value)
52    }
53    pub const fn get(self) -> i32 {
54        self.0
55    }
56    pub(crate) fn next(self) -> Option<Self> {
57        self.0.checked_add(1).map(Self)
58    }
59}
60
61/// A diagnostic as the server sent it: server-domain columns until the
62/// editor resolves them against its rope with the negotiated encoding.
63#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
64pub struct Diag {
65    pub line: LineIndex,
66    pub col: ServerColumn,
67    pub end_line: LineIndex,
68    pub end_col: ServerColumn,
69    pub severity: Severity,
70    pub message: String,
71}
72
73impl Diag {
74    /// Convert to the editor's byte domain. Lines beyond the current
75    /// document (the server computed on older content) clamp instead of
76    /// panicking; empty documents resolve to line 0.
77    pub fn resolve(self, encoding: PositionEncoding, buffer: &strop_core::Buffer) -> ResolvedDiag {
78        let last = buffer.len_lines().saturating_sub(1);
79        let line = LineIndex::new(self.line.get().min(last));
80        let end_line = LineIndex::new(self.end_line.get().min(last));
81        let start_text = buffer.line_text(line);
82        let end_text = if end_line == line {
83            start_text.clone()
84        } else {
85            buffer.line_text(end_line)
86        };
87        ResolvedDiag {
88            line,
89            col: to_byte_col(&start_text, self.col, encoding),
90            end_line,
91            end_col: to_byte_col(&end_text, self.end_col, encoding),
92            severity: self.severity,
93            message: self.message,
94        }
95    }
96}
97
98/// A diagnostic in the editor's byte domain: columns are UTF-8 byte
99/// offsets into the line, ready for gutter/underline math.
100#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
101pub struct ResolvedDiag {
102    pub line: LineIndex,
103    pub col: ByteColumn,
104    pub end_line: LineIndex,
105    pub end_col: ByteColumn,
106    pub severity: Severity,
107    pub message: String,
108}
109
110impl ResolvedDiag {
111    pub fn severity_char(&self) -> char {
112        self.severity.char()
113    }
114}
115
116#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
117#[serde(transparent)]
118pub struct ServerId(u64);
119impl ServerId {
120    pub const fn new(value: u64) -> Self {
121        Self(value)
122    }
123    pub const fn get(self) -> u64 {
124        self.0
125    }
126    pub(crate) fn allocate() -> Self {
127        static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
128        match NEXT.fetch_update(
129            std::sync::atomic::Ordering::Relaxed,
130            std::sync::atomic::Ordering::Relaxed,
131            |n| n.checked_add(1),
132        ) {
133            Ok(value) => Self(value),
134            Err(_) => panic!("LSP server identity exhausted"),
135        }
136    }
137}
138
139#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
140#[serde(transparent)]
141pub struct RequestId(u64);
142impl RequestId {
143    pub const fn new(value: u64) -> Self {
144        Self(value)
145    }
146    pub const fn get(self) -> u64 {
147        self.0
148    }
149}
150
151#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
152pub struct RequestStamp {
153    pub request: RequestId,
154    pub server: ServerId,
155    pub document: DocumentId,
156    pub revision: BufferRevision,
157}
158
159#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
160pub enum PositionEncoding {
161    Utf8,
162    Utf16,
163}
164
165/// Byte column → server column for one line's text.
166pub fn to_server_col(line: &str, byte_col: ByteColumn, enc: PositionEncoding) -> ServerColumn {
167    crate::to_server_col_slice(line.into(), byte_col, enc)
168}
169
170/// Server column → byte column for one line's text.
171pub fn to_byte_col(line: &str, server_col: ServerColumn, enc: PositionEncoding) -> ByteColumn {
172    crate::to_byte_col_slice(line.into(), server_col, enc)
173}
174
175#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
176pub enum LocKind {
177    References,
178    Implementation,
179    TypeDefinition,
180    Declaration,
181}
182impl LocKind {
183    pub fn label(self) -> &'static str {
184        match self {
185            Self::References => "references",
186            Self::Implementation => "implementation",
187            Self::TypeDefinition => "type definition",
188            Self::Declaration => "declaration",
189        }
190    }
191}
192
193#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
194pub enum RequestKind {
195    Goto,
196    Hover,
197    SwitchHeader,
198    Locations(LocKind),
199    Format,
200    Rename,
201    CodeAction,
202    /// All symbols in one document — no position rides the request.
203    DocumentSymbols,
204    /// All symbols in the workspace matching a query string —
205    /// document-free (0063 §2).
206    WorkspaceSymbols,
207}
208
209impl RequestKind {
210    pub fn label(self) -> &'static str {
211        match self {
212            Self::Goto => "goto definition",
213            Self::Hover => "hover",
214            Self::SwitchHeader => "switch source/header",
215            Self::Locations(kind) => kind.label(),
216            Self::Format => "format",
217            Self::Rename => "rename",
218            Self::CodeAction => "code action",
219            Self::DocumentSymbols => "document symbols",
220            Self::WorkspaceSymbols => "workspace symbols",
221        }
222    }
223}
224
225/// Why a request was never admitted (R9: no silent `None`). Refused
226/// requests get no stamp and no wire traffic; the caller reports them.
227#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
228pub enum RequestRefusal {
229    /// The document is not open on this connection.
230    NotOpen,
231    /// The buffer moved past the captured revision — re-request.
232    StaleRevision,
233    /// The server advertised no provider for this request kind.
234    Unsupported,
235    /// The server has not finished initializing — ask again later.
236    NotReady,
237    /// The monotonic request-id domain has no unused identity.
238    IdentityExhausted,
239    /// The bounded wire queue is full — the connection is not
240    /// draining (0056 AR06). Visible refusal, never a silent drop.
241    Overloaded,
242}
243
244#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
245#[serde(transparent)]
246pub struct ServerColumn(usize);
247impl ServerColumn {
248    pub const fn new(value: usize) -> Self {
249        Self(value)
250    }
251    pub const fn get(self) -> usize {
252        self.0
253    }
254}
255
256#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
257pub struct ServerPosition {
258    pub line: LineIndex,
259    pub column: ServerColumn,
260}
261
262/// One document-symbol row, flattened from either reply shape:
263/// hierarchical `DocumentSymbol[]` (container = ancestor path) or
264/// legacy flat `SymbolInformation[]` (container = its containerName).
265#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
266pub struct ProtoSymbol {
267    pub name: String,
268    pub container: String,
269    /// SymbolKind's LSP name (`Function`, `Struct`, …).
270    pub kind: String,
271    pub location: ServerLocation,
272}
273
274#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
275pub struct ServerLocation {
276    /// Endpoint-scoped identity of the target: a remote location can
277    /// never alias the analogous local path.
278    pub doc: ResourceLocation,
279    pub position: ServerPosition,
280}
281
282/// One text replacement in the server domain: lines/columns are the
283/// server's negotiated coordinates until the editor resolves them
284/// against its rope, exactly like [`Diag`].
285#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
286pub struct ServerEdit {
287    pub start: ServerPosition,
288    pub end: ServerPosition,
289    pub new_text: String,
290}
291
292/// A code action's usable payload. Command-only actions carry
293/// `edits: None` with `has_external_command: true`. An action whose
294/// edit cannot be applied (file operations, unverifiable versions)
295/// keeps its title with `edits: None` and `has_external_command:
296/// false` — the editor lists it but marks it inapplicable.
297#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
298pub struct ProtoAction {
299    pub title: String,
300    pub edits: Option<Vec<(ResourceLocation, Vec<ServerEdit>)>>,
301    pub has_external_command: bool,
302}
303
304#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
305pub struct ReplyContext {
306    pub stamp: RequestStamp,
307    pub encoding: PositionEncoding,
308    pub kind: RequestKind,
309}
310
311/// Source position remains byte-native until initialize negotiates
312/// encoding. Serializable: the replay tape records admissions and
313/// relaunches against the identical payload.
314#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
315pub struct RequestInput {
316    pub document: DocumentId,
317    pub revision: BufferRevision,
318    #[serde(with = "strop_core::path_serde")]
319    pub path: PathBuf,
320    pub line: LineIndex,
321    pub byte_col: ByteColumn,
322    pub line_text: crate::FrozenLine,
323    pub kind: RequestKind,
324    /// The rename target; `None` for every non-rename request. Old
325    /// tapes decode without it.
326    #[serde(default)]
327    pub rename_to: Option<String>,
328}
329
330/// An admitted request: its owning stamp plus the captured input.
331#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
332pub struct PendingRequest {
333    pub stamp: RequestStamp,
334    pub input: RequestInput,
335    /// Format options ride the admission record, not the input: a
336    /// formatting request has no cursor position, and the tape
337    /// serializes this record at `lsp.launch`, so a replayed format
338    /// relaunches with the recorded tab width. `None` for non-format
339    /// requests; old tapes decode without it.
340    #[serde(default)]
341    pub tab_width: Option<usize>,
342}
343
344#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
345pub struct DiagnosticContext {
346    pub server: ServerId,
347    pub document: DocumentId,
348    /// Current sent revision at receipt, not proof of computation freshness
349    /// for versionless diagnostics.
350    pub revision: BufferRevision,
351    pub encoding: PositionEncoding,
352    pub version: Option<WireVersion>,
353}
354
355#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
356pub enum LspEvent {
357    Diagnostics {
358        context: DiagnosticContext,
359        /// The diagnosed document: endpoint-scoped, so remote
360        /// diagnostics never collide with same-bytes local paths.
361        doc: ResourceLocation,
362        diags: Vec<Diag>,
363    },
364    Ready {
365        server: ServerId,
366        name: String,
367    },
368    Failed {
369        server: ServerId,
370        name: String,
371        hint: String,
372    },
373    /// A server-initiated `window/showMessage`: user-facing, from the
374    /// owning server. `window/logMessage` stays in the trace — it is
375    /// logging, not a message.
376    ServerMessage {
377        server: ServerId,
378        name: String,
379        text: String,
380    },
381    HoverText {
382        context: ReplyContext,
383        text: String,
384    },
385    GotoLocation {
386        context: ReplyContext,
387        location: ServerLocation,
388    },
389    Locations {
390        context: ReplyContext,
391        kind: LocKind,
392        items: Vec<ServerLocation>,
393    },
394    /// Formatting reply: the document's replacement spans in
395    /// server-domain positions (empty when the server has no changes).
396    Edits {
397        context: ReplyContext,
398        edits: Vec<ServerEdit>,
399    },
400    /// Rename or an edit-bearing code action: per-resource edit groups
401    /// in server-domain positions.
402    WorkspaceEdits {
403        context: ReplyContext,
404        edits: Vec<(ResourceLocation, Vec<ServerEdit>)>,
405    },
406    /// Code-action reply: the server's listed actions with their
407    /// usable payloads.
408    ActionList {
409        context: ReplyContext,
410        actions: Vec<ProtoAction>,
411    },
412    /// Document-symbol reply: the flattened tree (0047 §1) — both
413    /// server reply shapes land in the same row form.
414    Symbols {
415        context: ReplyContext,
416        symbols: Vec<ProtoSymbol>,
417    },
418    /// Workspace-symbol reply (0063 §2): document-free, so ownership
419    /// rides the caller's generation, not a document stamp.
420    WorkspaceSymbols {
421        server: ServerId,
422        generation: u64,
423        symbols: Vec<ProtoSymbol>,
424    },
425    /// The workspace-symbol request failed on this server (R9: the
426    /// request still ends in exactly one terminal event).
427    WorkspaceSymbolsFailed {
428        server: ServerId,
429        generation: u64,
430        reason: String,
431    },
432    /// context is the ORIGINAL request's — never re-derived.
433    Note {
434        context: ReplyContext,
435        text: String,
436    },
437}