Skip to main content

bynkc_lsp/
inlay_hints.rs

1//! v0.27 (ADR 0056): pure `inlayHint` computation — inferred-type hints
2//! from a cached analysis round's harvested hint set.
3//!
4//! The hints arrive pre-curated from `bynkc` (annotation-absent `let` /
5//! `let <-` bindings and lambda parameters, labels pre-rendered via
6//! `Ty::display()`); this module only filters to the requested visible
7//! range and converts positions against the analysed snapshot (the v0.24
8//! rule). v0.39 (ADR 0072): the harvested set now carries a [`HintKind`] —
9//! **`Type`** hints anchor at the span's **end** (the label's leading `: ` /
10//! `[` reads as source: `x: Int`, `identity[Int]`, no padding); **`Parameter`**
11//! hints anchor at the argument span's **start** with trailing padding, so the
12//! label reads `count: 5`.
13
14use bynk_check::hints::{Hint, HintKind};
15use bynk_check::requirements::Requirement;
16use bynk_syntax::span::Span;
17use std::collections::HashSet;
18use tower_lsp::lsp_types::*;
19
20use crate::position::{offset_to_position, span_to_range};
21
22/// The hints whose anchor falls inside the requested range. `text` is the
23/// analysed snapshot the spans are offsets into; `hints` is one file's
24/// harvested [`Hint`] set.
25pub fn inlay_hints(text: &str, hints: &[Hint], requested: Span) -> Vec<InlayHint> {
26    hints
27        .iter()
28        .filter_map(|h| {
29            let anchor = match h.kind {
30                HintKind::Type => h.span.end,
31                HintKind::Parameter => h.span.start,
32            };
33            (requested.start <= anchor && anchor <= requested.end).then(|| {
34                let (kind, padding_right) = match h.kind {
35                    HintKind::Type => (InlayHintKind::TYPE, None),
36                    HintKind::Parameter => (InlayHintKind::PARAMETER, Some(true)),
37                };
38                InlayHint {
39                    position: offset_to_position(text, anchor),
40                    label: InlayHintLabel::String(h.label.clone()),
41                    kind: Some(kind),
42                    text_edits: None,
43                    tooltip: None,
44                    padding_left: None,
45                    padding_right,
46                    data: None,
47                }
48            })
49        })
50        .collect()
51}
52
53/// v0.99 (DECISION E): the materializable ghost `given` inlay hints for a file.
54///
55/// Each **uncovered** capability requirement (its enclosing handler does not
56/// declare the capability) renders a ghost clause at the handler's declaration
57/// site — `… -> Effect[()]` `«given Clock»` — whose `text_edits` write the real
58/// clause via the same `given_insertion_edit` the undeclared-capability
59/// quick-fix uses. Deduplicated per `(insertion point, capability)` so one
60/// handler that consumes a capability at several sites offers a single ghost.
61/// The ghost is positioned where the clause would be inserted, so accepting it
62/// reads naturally (`given Clock`, or `, Clock` after an existing clause).
63pub fn given_hints(text: &str, requirements: &[Requirement], requested: Span) -> Vec<InlayHint> {
64    let mut seen: HashSet<(usize, String)> = HashSet::new();
65    let mut out = Vec::new();
66    for req in requirements {
67        // Covered requirements carry no materialization — they explain hover,
68        // not the ghost clause.
69        let Some(m) = &req.materialize else {
70            continue;
71        };
72        let anchor = m.edit_span.start;
73        if anchor < requested.start || anchor > requested.end {
74            continue;
75        }
76        if !seen.insert((anchor, req.capability.clone())) {
77            continue;
78        }
79        // The label mirrors the exact insertion (` given Clock` → `given Clock`,
80        // `, Clock` → `, Clock`); `padding_left` restores the leading space.
81        let label = m.edit_text.trim_start().to_string();
82        out.push(InlayHint {
83            position: offset_to_position(text, anchor),
84            label: InlayHintLabel::String(label),
85            kind: Some(InlayHintKind::TYPE),
86            text_edits: Some(vec![TextEdit {
87                range: span_to_range(text, m.edit_span),
88                new_text: m.edit_text.clone(),
89            }]),
90            tooltip: Some(InlayHintTooltip::String(format!(
91                "{} — {}",
92                req.capability,
93                req.source.reason(&req.capability)
94            ))),
95            padding_left: Some(true),
96            padding_right: None,
97            data: None,
98        });
99    }
100    out
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106    use bynk_check::requirements::{Materialize, RequirementSource, StoreKind};
107
108    fn label_of(h: &InlayHint) -> &str {
109        match &h.label {
110            InlayHintLabel::String(s) => s,
111            other => panic!("expected a plain string label, got {other:?}"),
112        }
113    }
114
115    fn type_hint(start: usize, end: usize, label: &str) -> Hint {
116        Hint {
117            span: Span::new(start, end),
118            label: label.to_string(),
119            kind: HintKind::Type,
120        }
121    }
122
123    #[test]
124    fn type_hints_anchor_after_the_name_with_no_padding() {
125        //                0123456789
126        let text = "let x = 1\nlet y = 2\n";
127        let hints = vec![type_hint(4, 5, ": Int"), type_hint(14, 15, ": Int")];
128        let got = inlay_hints(text, &hints, Span::new(0, text.len()));
129        assert_eq!(got.len(), 2);
130        // Anchored at the end of `x` (line 0, col 5) and `y` (line 1, col 5).
131        assert_eq!(got[0].position, Position::new(0, 5));
132        assert_eq!(label_of(&got[0]), ": Int");
133        assert_eq!(got[0].kind, Some(InlayHintKind::TYPE));
134        assert_eq!(got[0].padding_right, None);
135        assert_eq!(got[1].position, Position::new(1, 5));
136    }
137
138    #[test]
139    fn parameter_hints_anchor_before_the_argument_with_trailing_padding() {
140        // text: `f(5)` — the argument `5` is at offset 2..3.
141        let text = "f(5)\n";
142        let hints = vec![Hint {
143            span: Span::new(2, 3),
144            label: "count:".to_string(),
145            kind: HintKind::Parameter,
146        }];
147        let got = inlay_hints(text, &hints, Span::new(0, text.len()));
148        assert_eq!(got.len(), 1);
149        // Anchored at the *start* of the argument (col 2), with trailing space.
150        assert_eq!(got[0].position, Position::new(0, 2));
151        assert_eq!(label_of(&got[0]), "count:");
152        assert_eq!(got[0].kind, Some(InlayHintKind::PARAMETER));
153        assert_eq!(got[0].padding_right, Some(true));
154    }
155
156    #[test]
157    fn out_of_range_hints_are_filtered() {
158        let text = "let x = 1\nlet y = 2\n";
159        let hints = vec![type_hint(4, 5, ": Int"), type_hint(14, 15, ": Int")];
160        // Only the first line is visible.
161        let got = inlay_hints(text, &hints, Span::new(0, 9));
162        assert_eq!(got.len(), 1);
163        assert_eq!(got[0].position, Position::new(0, 5));
164    }
165
166    #[test]
167    fn empty_hint_set_returns_empty() {
168        assert!(inlay_hints("let x = 1\n", &[], Span::new(0, 9)).is_empty());
169    }
170
171    fn uncovered(cap: &str, site: usize, anchor: usize, edit_text: &str) -> Requirement {
172        Requirement {
173            capability: cap.to_string(),
174            site: Span::new(site, site + 1),
175            source: RequirementSource::StoreOp {
176                kind: StoreKind::Cache,
177                op: "put".to_string(),
178            },
179            covered: false,
180            materialize: Some(Materialize {
181                anchor: Span::new(anchor, anchor),
182                edit_span: Span::new(anchor, anchor),
183                edit_text: edit_text.to_string(),
184            }),
185        }
186    }
187
188    #[test]
189    fn ghost_given_renders_at_the_insertion_point_with_a_materialization_edit() {
190        // `-> Effect[()]` ends at offset 13; the ghost renders there.
191        let text = "on call f() -> Effect[()] {\n}\n";
192        let reqs = vec![uncovered("Clock", 20, 25, " given Clock")];
193        let got = given_hints(text, &reqs, Span::new(0, text.len()));
194        assert_eq!(got.len(), 1);
195        assert_eq!(label_of(&got[0]), "given Clock");
196        assert_eq!(got[0].padding_left, Some(true));
197        let edits = got[0].text_edits.as_ref().expect("materialization edit");
198        assert_eq!(edits.len(), 1);
199        assert_eq!(edits[0].new_text, " given Clock");
200    }
201
202    #[test]
203    fn ghost_given_dedups_per_handler_and_capability() {
204        // Two uncovered Clock sites in one handler → one ghost (same anchor).
205        let text = "on call f() -> Effect[()] {\n}\n";
206        let reqs = vec![
207            uncovered("Clock", 20, 25, " given Clock"),
208            uncovered("Clock", 30, 25, " given Clock"),
209        ];
210        let got = given_hints(text, &reqs, Span::new(0, text.len()));
211        assert_eq!(got.len(), 1, "deduped to a single ghost");
212    }
213
214    #[test]
215    fn covered_requirements_render_no_ghost() {
216        let text = "on call f() -> Effect[()] given Clock {\n}\n";
217        let covered = Requirement {
218            capability: "Clock".to_string(),
219            site: Span::new(20, 21),
220            source: RequirementSource::StoreOp {
221                kind: StoreKind::Cache,
222                op: "put".to_string(),
223            },
224            covered: true,
225            materialize: None,
226        };
227        assert!(given_hints(text, &[covered], Span::new(0, text.len())).is_empty());
228    }
229}