Skip to main content

logicaffeine_lsp/
inlay_hints.rs

1use tower_lsp::lsp_types::{InlayHint, InlayHintKind, InlayHintLabel, InlayHintTooltip, MarkupContent, MarkupKind, Range};
2
3use logicaffeine_compile::analysis::VarState;
4use logicaffeine_language::token::TokenType;
5
6use crate::document::DocumentState;
7use crate::index::{DefinitionKind, resolve_token_name};
8
9/// Handle inlay hints request.
10///
11/// Shows type annotations for variables declared without explicit types.
12pub fn inlay_hints(doc: &DocumentState, range: Range) -> Vec<InlayHint> {
13    let mut hints = Vec::new();
14    let is_default_range = range == Range::default();
15
16    for def in &doc.symbol_index.definitions {
17        if def.kind != DefinitionKind::Variable {
18            continue;
19        }
20        if def.span == logicaffeine_language::token::Span::default() {
21            continue;
22        }
23
24        if let Some(detail) = &def.detail {
25            if detail.contains("(inferred)") {
26                let pos = doc.line_index.position(def.span.end);
27                if !is_default_range && (pos.line < range.start.line || pos.line > range.end.line) {
28                    continue;
29                }
30                let type_label = extract_inferred_type(detail);
31                hints.push(InlayHint {
32                    position: pos,
33                    label: InlayHintLabel::String(format!(": {}", type_label)),
34                    kind: Some(InlayHintKind::TYPE),
35                    text_edits: None,
36                    tooltip: None,
37                    padding_left: Some(false),
38                    padding_right: Some(true),
39                    data: None,
40                });
41            }
42        }
43    }
44
45    // Ownership state hints for non-Owned variables
46    for token in &doc.tokens {
47        if !matches!(
48            &token.kind,
49            TokenType::Identifier | TokenType::ProperName(_)
50            | TokenType::Adjective(_) | TokenType::Noun(_)
51        ) {
52            continue;
53        }
54        if token.span == logicaffeine_language::token::Span::default() {
55            continue;
56        }
57
58        let pos = doc.line_index.position(token.span.end);
59        if !is_default_range && (pos.line < range.start.line || pos.line > range.end.line) {
60            continue;
61        }
62
63        if let Some(name) = resolve_token_name(token, &doc.interner) {
64            if let Some(state) = doc.ownership_states.get(name) {
65                let (label, tooltip) = match state {
66                    VarState::Moved => ("moved", "This variable has been given away and can no longer be used."),
67                    VarState::MaybeMoved => ("maybe moved", "This variable might have been given away in a conditional branch."),
68                    VarState::Borrowed => ("borrowed", "This variable is currently borrowed (lent via Show)."),
69                    VarState::Owned => continue, // Don't show for Owned — that's noise
70                };
71                hints.push(InlayHint {
72                    position: pos,
73                    label: InlayHintLabel::String(format!(" {}", label)),
74                    kind: Some(InlayHintKind::PARAMETER),
75                    text_edits: None,
76                    tooltip: Some(InlayHintTooltip::MarkupContent(MarkupContent {
77                        kind: MarkupKind::Markdown,
78                        value: tooltip.to_string(),
79                    })),
80                    padding_left: Some(true),
81                    padding_right: Some(false),
82                    data: None,
83                });
84            }
85        }
86    }
87
88    hints
89}
90
91/// Extract the inferred type from a detail string like "Let x: Int (inferred)".
92/// Returns the type name (e.g. "Int") or "auto" if not found.
93fn extract_inferred_type(detail: &str) -> &str {
94    // Format: "Let [mut ]name: TypeName (inferred)"
95    if let Some(colon_pos) = detail.rfind(": ") {
96        let after_colon = &detail[colon_pos + 2..];
97        if let Some(paren_pos) = after_colon.find(" (inferred)") {
98            let ty = after_colon[..paren_pos].trim();
99            if !ty.is_empty() {
100                return ty;
101            }
102        }
103    }
104    "auto"
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110    use crate::document::DocumentState;
111
112    fn make_doc(source: &str) -> DocumentState {
113        DocumentState::new(source.to_string(), 1)
114    }
115
116    #[test]
117    fn inlay_hints_for_inferred_integer() {
118        let doc = make_doc("## Main\n    Let x be 5.\n");
119        let range = Range::default();
120        let hints = inlay_hints(&doc, range);
121        assert!(!hints.is_empty(), "Expected inlay hint for inferred variable");
122        match &hints[0].label {
123            InlayHintLabel::String(s) => {
124                assert_eq!(s, ": Int", "Integer literal should infer Int, got '{}'", s);
125            }
126            _ => panic!("Expected string label"),
127        }
128    }
129
130    #[test]
131    fn inlay_hints_for_inferred_text() {
132        let doc = make_doc("## Main\n    Let msg be \"hello\".\n");
133        let range = Range::default();
134        let hints = inlay_hints(&doc, range);
135        assert!(!hints.is_empty(), "Expected inlay hint for text variable");
136        match &hints[0].label {
137            InlayHintLabel::String(s) => {
138                assert_eq!(s, ": Text", "Text literal should infer Text, got '{}'", s);
139            }
140            _ => panic!("Expected string label"),
141        }
142    }
143
144    #[test]
145    fn inlay_hints_for_inferred_bool() {
146        let doc = make_doc("## Main\n    Let flag be true.\n");
147        let range = Range::default();
148        let hints = inlay_hints(&doc, range);
149        assert!(!hints.is_empty(), "Expected inlay hint for bool variable");
150        match &hints[0].label {
151            InlayHintLabel::String(s) => {
152                assert_eq!(s, ": Bool", "Bool literal should infer Bool, got '{}'", s);
153            }
154            _ => panic!("Expected string label"),
155        }
156    }
157
158    #[test]
159    fn inlay_hints_empty_for_empty_doc() {
160        let doc = make_doc("");
161        let range = Range::default();
162        let hints = inlay_hints(&doc, range);
163        assert!(hints.is_empty());
164    }
165
166    #[test]
167    fn inlay_hints_skip_default_spans() {
168        let doc = make_doc("## Main\n    Let x be 5.\n");
169        let range = Range::default();
170        let hints = inlay_hints(&doc, range);
171        for hint in &hints {
172            assert_eq!(hint.kind, Some(InlayHintKind::TYPE), "Hints should be TYPE kind");
173        }
174    }
175
176    #[test]
177    fn inlay_hints_hint_position_correct() {
178        let doc = make_doc("## Main\n    Let x be 5.\n");
179        let range = Range::default();
180        let hints = inlay_hints(&doc, range);
181        assert!(!hints.is_empty(), "Should have at least one inlay hint for inferred variable");
182        let hint = &hints[0];
183        assert_eq!(hint.position.line, 1, "Hint should be on line 1 (the Let statement)");
184    }
185
186    #[test]
187    fn extract_inferred_type_from_detail() {
188        assert_eq!(extract_inferred_type("Let x: Int (inferred)"), "Int");
189        assert_eq!(extract_inferred_type("Let x: Text (inferred)"), "Text");
190        assert_eq!(extract_inferred_type("Let x: auto (inferred)"), "auto");
191        assert_eq!(extract_inferred_type("Let x: Int"), "auto"); // no "(inferred)" marker
192    }
193
194    #[test]
195    fn inlay_hints_respects_range_parameter() {
196        let doc = make_doc("## Main\n    Let x be 5.\n    Let y be 10.\n");
197        let range = tower_lsp::lsp_types::Range {
198            start: tower_lsp::lsp_types::Position { line: 1, character: 0 },
199            end: tower_lsp::lsp_types::Position { line: 1, character: 99 },
200        };
201        let restricted_hints = inlay_hints(&doc, range);
202        let all_hints = inlay_hints(&doc, Range::default());
203        assert!(restricted_hints.len() <= all_hints.len(),
204            "Restricted range should have <= hints: {} vs {}", restricted_hints.len(), all_hints.len());
205    }
206
207    #[test]
208    fn inlay_hint_borrowed_after_show() {
209        let doc = make_doc("## Main\n    Let x be 5.\n    Show x.\n");
210        let hints = inlay_hints(&doc, Range::default());
211        let borrowed_hints: Vec<_> = hints.iter()
212            .filter(|h| matches!(&h.label, InlayHintLabel::String(s) if s.contains("borrowed")))
213            .collect();
214        // x should be Borrowed after Show
215        if doc.ownership_states.get("x").map_or(false, |s| matches!(s, VarState::Borrowed)) {
216            assert!(
217                !borrowed_hints.is_empty(),
218                "Should have 'borrowed' inlay hint for x after Show"
219            );
220        }
221    }
222
223    #[test]
224    fn inlay_hint_no_marker_for_owned() {
225        let doc = make_doc("## Main\n    Let x be 5.\n");
226        let hints = inlay_hints(&doc, Range::default());
227        let ownership_hints: Vec<_> = hints.iter()
228            .filter(|h| matches!(&h.label, InlayHintLabel::String(s) if s.contains("moved") || s.contains("borrowed")))
229            .collect();
230        // x is Owned after just Let — no ownership hint should appear
231        assert!(
232            ownership_hints.is_empty(),
233            "Owned variables should not have ownership inlay hints. Got: {:?}",
234            ownership_hints.iter().map(|h| &h.label).collect::<Vec<_>>()
235        );
236    }
237}