Skip to main content

lattice_host/
lsp_helpers.rs

1//! Phase 5.5.LSP.1 step 2: pure LSP utility helpers, lifted from
2//! `lattice_ui_tui::app`. These functions translate between the
3//! editor's internal byte-indexed shapes and `lsp-types`'s wire
4//! shapes (UTF-16 column positions, hover-content markdown). They
5//! depend only on `lattice_core::Buffer`, `lattice_grammar::Position`,
6//! and `lattice_lsp::position` -- all host-side -- so they have no
7//! reason to live in the renderer crate.
8//!
9//! `lattice_ui_tui::app` re-exports both names under their original
10//! `crate::app::` paths so the existing ~18 call sites (App-side LSP
11//! request helpers) continue to compile unchanged through the rest
12//! of the LSP cluster migration.
13
14use lattice_core::Buffer;
15use lattice_protocol::Position;
16
17/// 5.5.LSP.2: word-class byte predicate -- ASCII alphanumerics
18/// and `_`. Mirrors the existing host-side `is_word_char_byte` in
19/// `dispatch.rs`; kept module-local to `lsp_helpers` for use by
20/// [`word_under_cursor`].
21fn is_word_char_byte(b: u8) -> bool {
22    b.is_ascii_alphanumeric() || b == b'_'
23}
24
25/// 5.5.LSP.2: extract the word-class span straddling `cursor` on
26/// the cursor's line. Returns `None` when the cursor is not on a
27/// word character -- "no symbol under cursor" is preferable to a
28/// label that jumps to a different identifier than the user
29/// pointed at. Used by the LSP nav / references dispatchers to
30/// label the tag-stack entry + the picker title.
31pub fn word_under_cursor(buffer: &Buffer, cursor: Position) -> Option<String> {
32    let line = buffer.line(cursor.line)?;
33    let bytes = line.as_bytes();
34    let byte_idx = cursor.byte as usize;
35    if byte_idx >= bytes.len() || !is_word_char_byte(bytes[byte_idx]) {
36        return None;
37    }
38    let mut start = byte_idx;
39    while start > 0 && is_word_char_byte(bytes[start - 1]) {
40        start -= 1;
41    }
42    let mut end = byte_idx;
43    while end < bytes.len() && is_word_char_byte(bytes[end]) {
44        end += 1;
45    }
46    if start == end {
47        return None;
48    }
49    Some(String::from_utf8_lossy(&bytes[start..end]).into_owned())
50}
51
52/// 5.5.LSP.5: single-character glyph for an LSP `SymbolKind`.
53/// Picked to fit a fixed-width column in picker rows so the
54/// marginalia column stays aligned. Falls back to `?` for kinds
55/// we don't have a specific glyph for.
56pub fn symbol_kind_glyph(kind: lattice_lsp::lsp_types::SymbolKind) -> &'static str {
57    use lattice_lsp::lsp_types::SymbolKind as K;
58    match kind {
59        K::FILE => "📄",
60        K::MODULE | K::NAMESPACE | K::PACKAGE => "📦",
61        K::CLASS | K::INTERFACE => "🅒",
62        K::METHOD | K::FUNCTION => "Æ’",
63        K::CONSTRUCTOR => "🅒",
64        K::PROPERTY | K::FIELD => "•",
65        K::VARIABLE => "v",
66        K::CONSTANT => "K",
67        K::STRING | K::NUMBER | K::BOOLEAN | K::ARRAY | K::OBJECT => "≡",
68        K::ENUM | K::ENUM_MEMBER => "🅔",
69        K::STRUCT => "🅢",
70        K::EVENT => "🅔",
71        K::OPERATOR => "⊕",
72        K::TYPE_PARAMETER => "T",
73        _ => "?",
74    }
75}
76
77/// 5.5.LSP.5: project an LSP `SymbolInformation` (legacy outline +
78/// workspace-symbol shape) into a `SymbolRow`. Returns `None`
79/// when the location's URI doesn't resolve to a path.
80pub fn symbol_information_to_row(
81    sym: &lattice_lsp::lsp_types::SymbolInformation,
82) -> Option<lattice_lsp::cache::SymbolRow> {
83    let path = lattice_lsp::actor::uri_to_path(&sym.location.uri)?;
84    Some(lattice_lsp::cache::SymbolRow {
85        name: sym.name.clone(),
86        kind_glyph: symbol_kind_glyph(sym.kind),
87        container: sym.container_name.clone(),
88        depth: 0,
89        path,
90        line: sym.location.range.start.line,
91        col: sym.location.range.start.character,
92    })
93}
94
95/// 5.5.LSP.5: flatten an LSP `DocumentSymbolResponse` into a
96/// pre-rendered `Vec<SymbolRow>`. The legacy
97/// `Flat(Vec<SymbolInformation>)` variant is one row per symbol
98/// with no nesting; the modern `Nested(Vec<DocumentSymbol>)`
99/// variant carries `children: Vec<DocumentSymbol>`, walked
100/// depth-first to preserve outline ordering.
101pub fn flatten_document_symbol_response(
102    resp: lattice_lsp::lsp_types::DocumentSymbolResponse,
103    path: &std::path::Path,
104    out: &mut Vec<lattice_lsp::cache::SymbolRow>,
105) {
106    match resp {
107        lattice_lsp::lsp_types::DocumentSymbolResponse::Flat(syms) => {
108            for sym in syms {
109                if let Some(row) = symbol_information_to_row(&sym) {
110                    out.push(row);
111                }
112            }
113        }
114        lattice_lsp::lsp_types::DocumentSymbolResponse::Nested(syms) => {
115            fn walk(
116                syms: Vec<lattice_lsp::lsp_types::DocumentSymbol>,
117                path: &std::path::Path,
118                depth: u32,
119                out: &mut Vec<lattice_lsp::cache::SymbolRow>,
120            ) {
121                for sym in syms {
122                    out.push(lattice_lsp::cache::SymbolRow {
123                        name: sym.name.clone(),
124                        kind_glyph: symbol_kind_glyph(sym.kind),
125                        container: None,
126                        depth,
127                        path: path.to_path_buf(),
128                        line: sym.selection_range.start.line,
129                        col: sym.selection_range.start.character,
130                    });
131                    if let Some(children) = sym.children {
132                        walk(children, path, depth + 1, out);
133                    }
134                }
135            }
136            walk(syms, path, 0, out);
137        }
138    }
139}
140
141/// 5.5.LSP.5: convert a modern (LSP 3.17+) `WorkspaceSymbol` into
142/// a `SymbolRow`. When the symbol's `location` came back as the
143/// `WorkspaceLocation` (URI-only) variant, fires
144/// `workspaceSymbol/resolve` against the originating server to
145/// upgrade to a real `Location` with `range`. Returns `None` when
146/// the URI doesn't map to a path; resolve failures fall back to
147/// `(0, 0)` so the row stays navigable.
148pub async fn workspace_symbol_to_row(
149    handle: &lattice_lsp::ServerHandle,
150    sym: lattice_lsp::lsp_types::WorkspaceSymbol,
151    token: &lattice_protocol::CancellationToken,
152) -> Option<lattice_lsp::cache::SymbolRow> {
153    use lattice_lsp::lsp_types::OneOf;
154    let (path, line, col) = match &sym.location {
155        OneOf::Left(loc) => (
156            lattice_lsp::actor::uri_to_path(&loc.uri)?,
157            loc.range.start.line,
158            loc.range.start.character,
159        ),
160        OneOf::Right(wsl) => {
161            let path = lattice_lsp::actor::uri_to_path(&wsl.uri)?;
162            // Server's resolveProvider absent -> no point firing.
163            // Fall back to (0, 0); the user can still navigate to
164            // the file.
165            if !handle.capabilities().workspace_symbol_resolve_provider() {
166                (path, 0, 0)
167            } else {
168                match handle
169                    .workspace_symbol_resolve(sym.clone(), token.clone())
170                    .await
171                {
172                    Ok(resolved) => match resolved.location {
173                        OneOf::Left(loc) => (
174                            lattice_lsp::actor::uri_to_path(&loc.uri).unwrap_or(path),
175                            loc.range.start.line,
176                            loc.range.start.character,
177                        ),
178                        OneOf::Right(_) => (path, 0, 0),
179                    },
180                    Err(_) => (path, 0, 0),
181                }
182            }
183        }
184    };
185    Some(lattice_lsp::cache::SymbolRow {
186        name: sym.name,
187        kind_glyph: symbol_kind_glyph(sym.kind),
188        container: sym.container_name,
189        depth: 0,
190        path,
191        line,
192        col,
193    })
194}
195
196/// 5.5.LSP.4: render an LSP `SignatureHelp` payload to a markdown
197/// string the popup renderer can display. Picks the active
198/// signature (server-supplied `active_signature` index, default
199/// 0) and inlines the active parameter's documentation when
200/// present. Returns the empty string when the response carries no
201/// signatures -- the caller surfaces "no signature info".
202pub fn signature_help_to_markdown(sh: &lattice_lsp::lsp_types::SignatureHelp) -> String {
203    if sh.signatures.is_empty() {
204        return String::new();
205    }
206    let active_sig_idx = sh.active_signature.unwrap_or(0) as usize;
207    let sig = sh
208        .signatures
209        .get(active_sig_idx)
210        .or_else(|| sh.signatures.first())
211        .expect("non-empty checked above");
212    let mut out = String::new();
213    // Active signature's call form -- fenced code block so the
214    // popup's markdown highlighter picks up syntax highlighting.
215    out.push_str("```text\n");
216    out.push_str(&sig.label);
217    out.push_str("\n```\n");
218    // Parameter highlight: append a short note pointing at the
219    // active parameter's name.
220    if let Some(active_param_idx) = sig.active_parameter.or(sh.active_parameter)
221        && let Some(params) = sig.parameters.as_ref()
222        && let Some(param) = params.get(active_param_idx as usize)
223    {
224        let label_str = match &param.label {
225            lattice_lsp::lsp_types::ParameterLabel::Simple(s) => s.clone(),
226            lattice_lsp::lsp_types::ParameterLabel::LabelOffsets(_) => String::new(),
227        };
228        if !label_str.is_empty() {
229            out.push_str(&format!("\n**param:** `{label_str}`\n"));
230        }
231        if let Some(doc) = param.documentation.as_ref() {
232            let doc_str = match doc {
233                lattice_lsp::lsp_types::Documentation::String(s) => s.clone(),
234                lattice_lsp::lsp_types::Documentation::MarkupContent(mc) => mc.value.clone(),
235            };
236            if !doc_str.is_empty() {
237                out.push('\n');
238                out.push_str(&doc_str);
239                out.push('\n');
240            }
241        }
242    }
243    // Signature-level documentation when present.
244    if let Some(doc) = sig.documentation.as_ref() {
245        let doc_str = match doc {
246            lattice_lsp::lsp_types::Documentation::String(s) => s.clone(),
247            lattice_lsp::lsp_types::Documentation::MarkupContent(mc) => mc.value.clone(),
248        };
249        if !doc_str.is_empty() {
250            out.push('\n');
251            out.push_str(&doc_str);
252            out.push('\n');
253        }
254    }
255    out
256}
257
258/// 5.5.LSP.4: single-character glyph for an LSP
259/// `CompletionItemKind`. Same shape as `symbol_kind_glyph` but
260/// maps the completion-item kind enum (which is wider -- snippets,
261/// keywords, folders, etc.). Used by the LSP completion picker /
262/// insert-completion overlay row marginalia.
263pub fn completion_kind_glyph(
264    kind: Option<lattice_lsp::lsp_types::CompletionItemKind>,
265) -> &'static str {
266    use lattice_lsp::lsp_types::CompletionItemKind as K;
267    match kind {
268        Some(K::FUNCTION) | Some(K::METHOD) | Some(K::CONSTRUCTOR) => "Æ’",
269        Some(K::VARIABLE) | Some(K::FIELD) | Some(K::PROPERTY) => "v",
270        Some(K::CONSTANT) => "K",
271        Some(K::CLASS) | Some(K::INTERFACE) => "🅒",
272        Some(K::STRUCT) => "🅢",
273        Some(K::ENUM) | Some(K::ENUM_MEMBER) => "🅔",
274        Some(K::MODULE) => "📦",
275        Some(K::FILE) | Some(K::FOLDER) => "📄",
276        Some(K::SNIPPET) => "✂",
277        Some(K::KEYWORD) => "K",
278        Some(K::TEXT) => "≡",
279        Some(K::REFERENCE) => "→",
280        _ => "?",
281    }
282}
283
284/// 5.5.LSP.2: flatten an LSP `GotoDefinitionResponse` (Scalar /
285/// Array / Link) into a uniform `Vec<Location>`. The `Link` shape
286/// carries richer per-result info (origin selection range used to
287/// highlight the symbol the user clicked); we drop it for now and
288/// keep the target location only -- the App's jump path is
289/// position-only. When 4.2.d's picker buffer lands the link
290/// metadata (e.g., `target_selection_range` for narrower jump
291/// destinations) becomes useful and this function gains a richer
292/// sibling.
293pub fn definition_response_to_locations(
294    resp: lattice_lsp::lsp_types::GotoDefinitionResponse,
295) -> Vec<lattice_lsp::lsp_types::Location> {
296    match resp {
297        lattice_lsp::lsp_types::GotoDefinitionResponse::Scalar(loc) => vec![loc],
298        lattice_lsp::lsp_types::GotoDefinitionResponse::Array(locs) => locs,
299        lattice_lsp::lsp_types::GotoDefinitionResponse::Link(links) => links
300            .into_iter()
301            .map(|l| lattice_lsp::lsp_types::Location {
302                uri: l.target_uri,
303                // `target_selection_range` is the narrower symbol
304                // range; `target_range` is the enclosing block.
305                // Picker UX usually wants the narrower one.
306                range: l.target_selection_range,
307            })
308            .collect(),
309    }
310}
311
312/// Convert an editor-side `Position` (line + utf-8 byte column)
313/// into the LSP-side `lattice_lsp::lsp_types::Position` (line + utf-16 code-
314/// unit column). Returns `None` when the line index is past the
315/// end of the buffer -- e.g. cursor on a sentinel row past EOF.
316pub fn app_to_lsp_position(
317    buffer: &Buffer,
318    p: Position,
319) -> Option<lattice_lsp::lsp_types::Position> {
320    let line_text = buffer.line(p.line)?;
321    let character = lattice_lsp::position::utf8_byte_to_utf16_column(&line_text, p.byte);
322    Some(lattice_lsp::lsp_types::Position {
323        line: p.line,
324        character,
325    })
326}
327
328/// Render an LSP `HoverContents` payload to a markdown string the
329/// renderer's hover popup pipeline can highlight via the markdown
330/// grammar.
331///
332/// `MarkedString::String(s)` keeps `s` verbatim.
333/// `MarkedString::LanguageString { language, value }` wraps
334/// `value` in a fenced code block tagged with `language` so the
335/// markdown injection picks it up. `MarkupContent` arrives pre-
336/// rendered as either markdown or plaintext (we treat plaintext
337/// as already-good markdown). `Array` joins each element with two
338/// newlines so blocks separate cleanly.
339/// 4.5.c: does the given `range` cover the LSP `position`?
340/// Inclusive on both ends (matches VSCode's click-through
341/// semantics on a link's rightmost char). Used by `gx` to find
342/// the first cached `documentLink` under the cursor. Phase
343/// 5.8.AD.2.
344pub fn range_covers(
345    range: lattice_lsp::lsp_types::Range,
346    position: lattice_lsp::lsp_types::Position,
347) -> bool {
348    let after_start =
349        (range.start.line, range.start.character) <= (position.line, position.character);
350    let before_end = (position.line, position.character) <= (range.end.line, range.end.character);
351    after_start && before_end
352}
353
354/// Render one `CallHierarchyItem` (the caller / callee) as a
355/// `SymbolRow` for the picker. The item's `detail` (if any)
356/// plus the originating callable's name ride in `container`.
357/// Phase 5.8.AD.2: hoisted from TUI App.
358pub fn call_hierarchy_to_row(
359    item: &lattice_lsp::lsp_types::CallHierarchyItem,
360    related_to: &str,
361) -> lattice_lsp::cache::SymbolRow {
362    let path = lattice_lsp::actor::uri_to_path(&item.uri).unwrap_or_default();
363    lattice_lsp::cache::SymbolRow {
364        name: item.name.clone(),
365        kind_glyph: symbol_kind_glyph(item.kind),
366        container: Some(format!("in {related_to}")),
367        depth: 0,
368        path,
369        line: item.selection_range.start.line,
370        col: item.selection_range.start.character,
371    }
372}
373
374/// 4.5.b: render one `TypeHierarchyItem` (a supertype or
375/// subtype of the cursor's type) as a [`SymbolRow`] for the
376/// picker. Same projection as `call_hierarchy_to_row` because
377/// the item shape is identical. Phase 5.8.AD.2.
378pub fn type_hierarchy_to_row(
379    item: &lattice_lsp::lsp_types::TypeHierarchyItem,
380    related_to: &str,
381) -> lattice_lsp::cache::SymbolRow {
382    let path = lattice_lsp::actor::uri_to_path(&item.uri).unwrap_or_default();
383    lattice_lsp::cache::SymbolRow {
384        name: item.name.clone(),
385        kind_glyph: symbol_kind_glyph(item.kind),
386        container: Some(format!("of {related_to}")),
387        depth: 0,
388        path,
389        line: item.selection_range.start.line,
390        col: item.selection_range.start.character,
391    }
392}
393
394/// Map an LSP `CodeActionKind` to a one-char glyph used in the
395/// picker margin (`🛠`, `♻`, `↗`, etc.). Unknown / custom kinds
396/// and bare `Command` payloads land on `?`. Phase 5.8.AD.2.
397pub fn code_action_kind_glyph(
398    kind: Option<&lattice_lsp::lsp_types::CodeActionKind>,
399) -> &'static str {
400    use lattice_lsp::lsp_types::CodeActionKind as K;
401    let Some(kind) = kind else {
402        return "?";
403    };
404    if *kind == K::QUICKFIX {
405        "🛠"
406    } else if *kind == K::REFACTOR {
407        "â™»"
408    } else if *kind == K::REFACTOR_EXTRACT {
409        "↗"
410    } else if *kind == K::REFACTOR_INLINE {
411        "↘"
412    } else if *kind == K::REFACTOR_REWRITE {
413        "↺"
414    } else if *kind == K::SOURCE {
415        "★"
416    } else if *kind == K::SOURCE_ORGANIZE_IMPORTS {
417        "≡"
418    } else if *kind == K::SOURCE_FIX_ALL {
419        "✓"
420    } else {
421        "?"
422    }
423}
424
425/// Extract a placeholder name from a `PrepareRenameResponse`, if
426/// the server supplied one. Used when the user invoked `:rename`
427/// without a name. Phase 5.8.AD.2.
428pub fn prepare_rename_placeholder(
429    resp: &lattice_lsp::lsp_types::PrepareRenameResponse,
430) -> Option<String> {
431    match resp {
432        lattice_lsp::lsp_types::PrepareRenameResponse::RangeWithPlaceholder {
433            placeholder, ..
434        } => Some(placeholder.clone()),
435        lattice_lsp::lsp_types::PrepareRenameResponse::Range(_) => None,
436        lattice_lsp::lsp_types::PrepareRenameResponse::DefaultBehavior { .. } => None,
437    }
438}
439
440/// Is the editor cursor inside the half-open LSP `range`?
441/// Compares only by (line, character) in LSP utf-16 space because
442/// the range is LSP-shape; the host cursor is assumed converted.
443/// Phase 5.8.AD.2: hoisted from TUI App.
444pub fn cursor_inside_range(
445    cursor: lattice_protocol::Position,
446    range: &lattice_lsp::lsp_types::Range,
447) -> bool {
448    let line = cursor.line;
449    let col = cursor.byte;
450    let start = (range.start.line, range.start.character);
451    let end = (range.end.line, range.end.character);
452    let here = (line, col);
453    here >= start && here < end
454}
455
456/// 4.4.e: flatten an LSP `SelectionRange` (linked list via
457/// `parent`) into a `Vec<Range>` ordered innermost-first.
458/// Phase 5.8.AD.2: hoisted from TUI App.
459pub fn flatten_selection_range_chain(
460    head: &lattice_lsp::lsp_types::SelectionRange,
461) -> Vec<lattice_lsp::lsp_types::Range> {
462    let mut out = Vec::new();
463    let mut cur = Some(head);
464    while let Some(node) = cur {
465        out.push(node.range);
466        cur = node.parent.as_deref();
467    }
468    out
469}
470
471pub fn hover_contents_to_markdown(contents: &lattice_lsp::lsp_types::HoverContents) -> String {
472    fn marked_to_markdown(m: &lattice_lsp::lsp_types::MarkedString) -> String {
473        match m {
474            lattice_lsp::lsp_types::MarkedString::String(s) => s.clone(),
475            lattice_lsp::lsp_types::MarkedString::LanguageString(ls) => {
476                format!("```{}\n{}\n```", ls.language, ls.value)
477            }
478        }
479    }
480    match contents {
481        lattice_lsp::lsp_types::HoverContents::Scalar(m) => marked_to_markdown(m),
482        lattice_lsp::lsp_types::HoverContents::Array(items) => items
483            .iter()
484            .map(marked_to_markdown)
485            .collect::<Vec<_>>()
486            .join("\n\n"),
487        lattice_lsp::lsp_types::HoverContents::Markup(m) => m.value.clone(),
488    }
489}