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}