Skip to main content

bynk_lsp/
documentation_request.rs

1//! #847: `bynk/documentationModel` — the documentation-view custom LSP request.
2//!
3//! The second custom request in this server (after #846's `bynk/sequenceModel`),
4//! and the same posture: no `workspace/*/refresh` nudge exists for a custom
5//! method and none is needed — Tier 1 is on-demand, the client re-issues the
6//! request each time "Bynk: Show Documentation" fires (Decision D).
7//!
8//! Unlike `bynk/sequenceModel`, the request carries **no cursor position**: a
9//! documentation page is the *whole unit's* declarations, so the params are a
10//! bare `TextDocumentIdentifier`. The page was file-scoped (Decision A) until
11//! #1885, which merges every file of a multi-file unit
12//! ([`documentation_model_for`]). The wire shape is a plain serde mirror of
13//! [`bynk_ide::documentation::DocModel`], each `Span` lowered to an LSP `Range`
14//! against the committed snapshot text of the file it is in — the same
15//! convention `sequence_request`/`SerKey` use — with a per-entry `uri` naming
16//! that file (the `{uri, range}` pattern `architecture_request` uses).
17
18use std::collections::HashMap;
19use std::path::{Path, PathBuf};
20
21use bynk_ide::documentation;
22use tower_lsp::lsp_types::Url;
23
24/// The `bynk/documentationModel` request payload. A bare text-document
25/// identifier — no cursor position (the page is the whole unit).
26///
27/// `rename_all = "camelCase"` is load-bearing: the client sends the LSP wire
28/// name `textDocument`, so the field must deserialize from camelCase, not the
29/// Rust `text_document`. (Without it, every request fails with a missing-field
30/// error — a wire-shape bug no direct `documentation_model_for` test would
31/// catch, only a deserialize test; see `documentation_request.rs`'s params
32/// test.)
33#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
34#[serde(rename_all = "camelCase")]
35pub struct DocumentationModelParams {
36    pub text_document: tower_lsp::lsp_types::TextDocumentIdentifier,
37}
38
39// -- Wire shape: a plain serde mirror of `bynk_ide::documentation::DocModel`,
40// -- `Span` lowered to `Range` against the committed snapshot text.
41
42#[derive(Debug, Clone, serde::Serialize)]
43pub struct WireDocModel {
44    #[serde(rename = "unitName")]
45    pub unit_name: String,
46    #[serde(rename = "unitKind")]
47    pub unit_kind: &'static str,
48    #[serde(rename = "unitDoc")]
49    pub unit_doc: Option<String>,
50    #[serde(rename = "unitRange")]
51    pub unit_range: tower_lsp::lsp_types::Range,
52    pub entries: Vec<WireDocEntry>,
53}
54
55#[derive(Debug, Clone, serde::Serialize)]
56pub struct WireDocEntry {
57    pub name: String,
58    pub kind: &'static str,
59    pub depth: u32,
60    pub markdown: String,
61    pub documented: bool,
62    pub range: tower_lsp::lsp_types::Range,
63    /// #1885: the document `range` is in. Always sent: the page is merged
64    /// across a unit's files, so an entry may live in a file other than the
65    /// requested one (and a one-file unit's entries simply all name it).
66    pub uri: tower_lsp::lsp_types::Url,
67}
68
69/// #1885: the wire page for the document at `rel` (project-relative), merged
70/// across every file of its unit. The unit's files come from the round's
71/// `unit_sources` (qualified name → project-relative paths) and their text
72/// from `snapshots`; each entry's range is lowered against *its own* file and
73/// carries that file's `file://` URI, so click-to-code opens the right one.
74/// An entry whose file has no snapshot or no URI is dropped rather than sent
75/// with a wrong location. `None` for a `suite`, a file with no header, or a
76/// `rel` with no snapshot.
77pub fn documentation_model_for(
78    rel: &Path,
79    snapshots: &HashMap<PathBuf, String>,
80    unit_sources: &HashMap<String, Vec<PathBuf>>,
81    project_root: &Path,
82) -> Option<WireDocModel> {
83    let text = snapshots.get(rel)?;
84    let siblings: Vec<(&Path, &str)> = bynk_ide::symbols::own_declaration_name(text)
85        .and_then(|(name, _)| unit_sources.get(&name))
86        .into_iter()
87        .flatten()
88        .filter_map(|p| snapshots.get(p).map(|t| (p.as_path(), t.as_str())))
89        .collect();
90    let model = documentation::documentation_model_merged((rel, text.as_str()), siblings)?;
91    // Each entry's range is lowered once, against the snapshot of the file
92    // its span is in.
93    let entries = model
94        .entries
95        .iter()
96        .filter_map(|e| {
97            let file = e.file.as_deref().unwrap_or(rel);
98            let file_text = snapshots.get(file)?;
99            Some(WireDocEntry {
100                name: e.name.clone(),
101                kind: e.kind,
102                depth: e.depth,
103                markdown: e.markdown.clone(),
104                documented: e.documented,
105                range: crate::position::span_to_range(file_text, e.span),
106                uri: Url::from_file_path(project_root.join(file)).ok()?,
107            })
108        })
109        .collect();
110    Some(WireDocModel {
111        unit_name: model.unit_name,
112        unit_kind: model.unit_kind,
113        unit_doc: model.unit_doc,
114        unit_range: crate::position::span_to_range(text, model.unit_span),
115        entries,
116    })
117}