Skip to main content

logicaffeine_lsp/
signature_help.rs

1use tower_lsp::lsp_types::{
2    ParameterInformation, ParameterLabel, Position, SignatureHelp, SignatureInformation,
3};
4
5use logicaffeine_language::token::TokenType;
6
7use crate::document::DocumentState;
8use crate::index::DefinitionKind;
9
10/// Handle signature help request.
11///
12/// When the cursor is inside a `Call` expression, find the function definition
13/// and show parameter names/types.
14pub fn signature_help(doc: &DocumentState, position: Position) -> Option<SignatureHelp> {
15    let offset = doc.line_index.offset(position);
16
17    // Scan backwards from cursor to find a Call token
18    let call_token = doc.tokens.iter().rev().find(|t| {
19        t.span.end <= offset && matches!(t.kind, TokenType::Call)
20    })?;
21
22    // Find the function name token (should be right after Call or after "with")
23    let call_idx = doc.tokens.iter().position(|t| {
24        t.span == call_token.span && t.kind == call_token.kind
25    })?;
26
27    // The function name should follow Call
28    let func_name_token = doc.tokens.get(call_idx + 1)?;
29    let func_name = doc.source.get(func_name_token.span.start..func_name_token.span.end)?;
30
31    // Look up the function definition; stdlib prelude names answer when the
32    // document defines nothing by that name.
33    let defs = doc.symbol_index.definitions_of(func_name);
34    let func_def = defs.iter().find(|d| d.kind == DefinitionKind::Function);
35    let (detail, documentation) = match func_def {
36        Some(def) => (def.detail.clone()?, def.doc.clone()),
37        None => {
38            let entry = crate::stdlib_docs::stdlib_doc(func_name).filter(|e| !e.is_type)?;
39            (entry.signature.trim_start_matches("## ").to_string(), entry.doc.clone())
40        }
41    };
42    let detail = &detail;
43
44    // Count parameter separators after Call to determine active parameter.
45    // "with" introduces the parameter list, only "and" and "," separate params.
46    let active_param = doc.tokens[call_idx..]
47        .iter()
48        .take_while(|t| t.span.start < offset)
49        .filter(|t| {
50            matches!(t.kind, TokenType::Comma)
51                || doc
52                    .source
53                    .get(t.span.start..t.span.end)
54                    .map(|s| s == "and")
55                    .unwrap_or(false)
56        })
57        .count();
58
59    // Extract parameters from the function's signature detail string
60    let params: Vec<ParameterInformation> = extract_params_from_signature(detail)
61        .into_iter()
62        .map(|(name, ty)| ParameterInformation {
63            label: ParameterLabel::Simple(name.clone()),
64            documentation: Some(tower_lsp::lsp_types::Documentation::String(
65                format!("{}: {}", name, ty),
66            )),
67        })
68        .collect();
69
70    Some(SignatureHelp {
71        signatures: vec![SignatureInformation {
72            label: detail.clone(),
73            documentation: documentation.map(|d| {
74                tower_lsp::lsp_types::Documentation::MarkupContent(
75                    tower_lsp::lsp_types::MarkupContent {
76                        kind: tower_lsp::lsp_types::MarkupKind::Markdown,
77                        value: d,
78                    },
79                )
80            }),
81            parameters: if params.is_empty() {
82                None
83            } else {
84                Some(params)
85            },
86            active_parameter: Some(active_param as u32),
87        }],
88        active_signature: Some(0),
89        active_parameter: Some(active_param as u32),
90    })
91}
92
93/// Extract parameter names and types from a function signature detail string.
94///
95/// Handles both the comma style (`"To name(a: Int, b: Int) -> Ret"`) and the
96/// stdlib's prepositional groups (`"To native f (a: Int) and (b: Int) -> R"`):
97/// every parenthesized group before the arrow contributes its parameters.
98fn extract_params_from_signature(detail: &str) -> Vec<(String, String)> {
99    let head = detail.split("->").next().unwrap_or(detail);
100    let mut params = Vec::new();
101    let mut rest = head;
102    while let Some(open) = rest.find('(') {
103        let Some(close) = rest[open + 1..].find(')') else { break };
104        let group = &rest[open + 1..open + 1 + close];
105        for part in group.split(',') {
106            let part = part.trim();
107            if part.is_empty() {
108                continue;
109            }
110            let mut split = part.splitn(2, ':');
111            let Some(name) = split.next().map(|s| s.trim().to_string()) else { continue };
112            let ty = split
113                .next()
114                .map(|s| s.trim().to_string())
115                .unwrap_or_else(|| "auto".to_string());
116            params.push((name, ty));
117        }
118        rest = &rest[open + 1 + close + 1..];
119    }
120    params
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126    use crate::document::DocumentState;
127
128    fn make_doc(source: &str) -> DocumentState {
129        DocumentState::new(source.to_string(), 1)
130    }
131
132    #[test]
133    fn signature_help_returns_none_without_call() {
134        let doc = make_doc("## Main\n    Let x be 5.\n");
135        let pos = Position { line: 1, character: 10 };
136        let result = signature_help(&doc, pos);
137        assert!(result.is_none(), "Should return None when not in a Call expression");
138    }
139
140    #[test]
141    fn signature_help_no_crash_empty_doc() {
142        let doc = make_doc("");
143        let pos = Position { line: 0, character: 0 };
144        let result = signature_help(&doc, pos);
145        assert!(result.is_none());
146    }
147
148    #[test]
149    fn signature_help_no_crash_on_out_of_bounds() {
150        let doc = make_doc("## Main\n    Let x be 5.\n");
151        let pos = Position { line: 5, character: 0 };
152        let result = signature_help(&doc, pos);
153        assert!(result.is_none(), "OOB should return None");
154    }
155
156    #[test]
157    fn extract_params_basic() {
158        let params = extract_params_from_signature("To add(a: Int, b: Int) -> Int");
159        assert_eq!(params.len(), 2);
160        assert_eq!(params[0], ("a".to_string(), "Int".to_string()));
161        assert_eq!(params[1], ("b".to_string(), "Int".to_string()));
162    }
163
164    #[test]
165    fn extract_params_multiple() {
166        let params = extract_params_from_signature("To greet(name: Text, age: Int, loud: Bool) -> Text");
167        assert_eq!(params.len(), 3);
168        assert_eq!(params[0].0, "name");
169        assert_eq!(params[1].0, "age");
170        assert_eq!(params[2].0, "loud");
171    }
172
173    #[test]
174    fn extract_params_empty() {
175        let params = extract_params_from_signature("To noop() -> Unit");
176        assert!(params.is_empty());
177    }
178
179    #[test]
180    fn extract_params_no_parens() {
181        let params = extract_params_from_signature("something without parens");
182        assert!(params.is_empty());
183    }
184
185    #[test]
186    fn signature_help_returns_signature_for_defined_function() {
187        let source = "## To add(a: Int, b: Int) -> Int\n    Return a + b.\n\n## Main\n    Let r be Call add with 1 and 2.\n";
188        let doc = make_doc(source);
189        let pos = Position { line: 4, character: 30 };
190        let result = signature_help(&doc, pos);
191        if let Some(help) = &result {
192            assert!(!help.signatures.is_empty(), "Should have a signature");
193            let sig = &help.signatures[0];
194            // Params should come from the function's own signature, not globally
195            if let Some(params) = &sig.parameters {
196                let names: Vec<&str> = params
197                    .iter()
198                    .map(|p| match &p.label {
199                        ParameterLabel::Simple(s) => s.as_str(),
200                        _ => "",
201                    })
202                    .collect();
203                assert!(names.contains(&"a"), "Should include param 'a': {:?}", names);
204                assert!(names.contains(&"b"), "Should include param 'b': {:?}", names);
205            }
206        }
207    }
208
209    #[test]
210    fn active_parameter_tracking() {
211        let source = "## To add(a: Int, b: Int) -> Int\n    Return a + b.\n\n## Main\n    Let r be Call add with 1 and 2.\n";
212        let doc = make_doc(source);
213        // Position after "and" separator → active_parameter should be >= 1
214        let pos = Position { line: 4, character: 35 };
215        if let Some(help) = signature_help(&doc, pos) {
216            let active = help.active_parameter.unwrap_or(0);
217            assert!(active >= 1, "After 'and' separator, active_parameter should be >= 1, got {}", active);
218        }
219    }
220
221    #[test]
222    fn call_not_found_returns_none() {
223        let doc = make_doc("## Main\n    Let x be 5.\n");
224        // Position before any Call token → should return None
225        let pos = Position { line: 1, character: 4 };
226        let result = signature_help(&doc, pos);
227        assert!(result.is_none(), "Should return None when no Call precedes position");
228    }
229
230    #[test]
231    fn with_not_counted_as_separator() {
232        // "with" introduces the parameter list, it should NOT increment active_parameter.
233        // In `Call add with 1 and 2`, active_param should be 0 right after "with 1",
234        // and 1 after "and".
235        let source = "## To add with a: Int and b: Int\n    Show a.\n\n## Main\n    Let r be Call add with 1 and 2.\n";
236        let doc = make_doc(source);
237        // Position just after "with 1" but before "and"
238        // "    Let r be Call add with 1 and 2.\n"
239        //  0123456789...
240        // We need a position after "1" but before "and"
241        let pos = Position { line: 4, character: 27 };
242        if let Some(help) = signature_help(&doc, pos) {
243            let active = help.active_parameter.unwrap_or(99);
244            assert_eq!(active, 0, "Before 'and', active_parameter should be 0 (with not counted), got {}", active);
245        }
246    }
247
248    #[test]
249    fn signature_help_documents_from_the_note_prose() {
250        let source = "## Note\nAdds two integers.\n\n## To compute (a: Int, b: Int) -> Int:\n    Return a + b.\n\n## Main\n    Let r be Call compute with 1 and 2.\n";
251        let doc = make_doc(source);
252        let pos = Position { line: 7, character: 34 };
253        let help = signature_help(&doc, pos).expect("documented function signature");
254        let sig = &help.signatures[0];
255        let Some(tower_lsp::lsp_types::Documentation::MarkupContent(content)) =
256            &sig.documentation
257        else {
258            panic!("the ## Note prose must document the signature");
259        };
260        assert!(content.value.contains("Adds two integers."), "{}", content.value);
261    }
262
263    #[test]
264    fn signature_help_falls_back_to_the_stdlib() {
265        let source = "## Main\n    Let r be Call randomInt with 1 and 6.\n";
266        let doc = make_doc(source);
267        let pos = Position { line: 1, character: 32 };
268        let help = signature_help(&doc, pos).expect("stdlib names answer signature help");
269        let sig = &help.signatures[0];
270        assert!(sig.label.contains("randomInt"), "{}", sig.label);
271        let params = sig.parameters.as_ref().expect("min and max parameters");
272        assert_eq!(params.len(), 2, "prepositional groups both count: {params:?}");
273        assert!(sig.documentation.is_some(), "the literate Note teaches");
274    }
275
276    #[test]
277    fn multi_group_signatures_yield_every_parameter() {
278        let params = extract_params_from_signature(
279            "To native write (path: Text) and (content: Text) -> Result of Unit and Text",
280        );
281        assert_eq!(params.len(), 2);
282        assert_eq!(params[0].0, "path");
283        assert_eq!(params[1].0, "content");
284    }
285
286    #[test]
287    fn signature_help_finds_function_via_span_not_pointer() {
288        // Regression test: call_idx lookup must use span equality, not pointer equality.
289        // Clone the doc's tokens to ensure different pointers but same spans.
290        let source = "## To add(a: Int, b: Int) -> Int\n    Return a + b.\n\n## Main\n    Let r be Call add with 1 and 2.\n";
291        let doc = make_doc(source);
292        // Position after "Call add" on the Call line
293        let pos = Position { line: 4, character: 30 };
294        let result = signature_help(&doc, pos);
295        // Should find the function regardless of pointer identity
296        // (This test passes trivially once pointer equality is replaced with span equality)
297        if let Some(help) = &result {
298            assert!(!help.signatures.is_empty(), "Should have at least one signature");
299        }
300    }
301}