Skip to main content

bynk_ide/
signature_help.rs

1//! v0.32 (ADR 0065): signature help — `textDocument/signatureHelp`.
2//!
3//! While typing a call's arguments, show the callee's signature with the active
4//! parameter highlighted. **Call-context detection is lexical** (the innermost
5//! unclosed `(` before the cursor, the callee before it, the active parameter
6//! from a bracket-aware comma count); **signatures are semantic**, resolved from
7//! the recovery parse + the static registries — the same name-vs-value split as
8//! completion. This slice covers **name callees**: free functions, capability
9//! operations, refined/opaque `of`/`unsafe`, built-in type statics
10//! (`Int.parse`/`Json.decode`), and the `Ok`/`Err`/`Some` constructors. Value
11//! receivers (`xs.fold(`) need the receiver typed → a later slice.
12//!
13//! The signature is rendered with `symbols::type_ref_str` — the same Bynk-syntax
14//! renderer hover uses — so the two never diverge.
15
16use bynk_syntax::ast::{BaseType, CommonsItem, FnName, SourceUnit, TypeBody};
17use std::collections::HashMap;
18use std::path::PathBuf;
19
20use crate::completion::{BUILTIN_STATICS, for_each_unit};
21use crate::symbols::type_ref_str;
22
23/// The call under the cursor: the callee text, the active-parameter index, and
24/// the byte offset of the call's opening `(`.
25#[derive(Debug, PartialEq, Eq)]
26pub struct CallContext {
27    pub callee: String,
28    pub active_param: usize,
29    pub open_paren: usize,
30}
31
32/// The innermost unclosed `(` before `offset`, its callee, and the active
33/// parameter (top-level commas between that `(` and the cursor). `None` when the
34/// cursor is not inside a call's argument list.
35pub fn call_context(text: &str, offset: usize) -> Option<CallContext> {
36    let prefix = text.get(..offset)?;
37    let open = innermost_unclosed_paren(prefix)?;
38    let callee = callee_before(&prefix[..open])?;
39    let active = top_level_commas(&prefix[open + 1..]);
40    Some(CallContext {
41        callee,
42        active_param: active,
43        open_paren: open,
44    })
45}
46
47/// A value-receiver method callee — `recv.method` where `recv` is a single
48/// lowercase-initial identifier (a value, not a type/capability name). The
49/// signature comes from typing the receiver (a later slice's path).
50pub fn value_receiver_method(callee: &str) -> Option<(&str, &str)> {
51    let (recv, method) = callee.rsplit_once('.')?;
52    let first = recv.chars().next()?;
53    if (first.is_ascii_lowercase() || first == '_') && !recv.contains('.') {
54        Some((recv, method))
55    } else {
56        None
57    }
58}
59
60/// For a value-receiver callee `recv.method(` whose `(` is at `open_paren`,
61/// rewrite the buffer so `recv` is a complete expression (the `.method(args`
62/// dropped) and return it with the receiver byte offset to type — the same
63/// mid-edit trick value-member completion uses.
64pub fn value_receiver_rewrite(
65    text: &str,
66    callee: &str,
67    open_paren: usize,
68    cursor: usize,
69) -> Option<(String, usize)> {
70    let (recv, _) = value_receiver_method(callee)?;
71    let callee_start = open_paren.checked_sub(callee.len())?;
72    let dot = callee_start + recv.len();
73    let rewritten = format!("{}{}", &text[..dot], &text[cursor..]);
74    Some((rewritten, dot.saturating_sub(1)))
75}
76
77/// The kernel-method signature for `method` on receiver type `ty`, if any.
78pub fn kernel_method_signature(
79    ty: bynk_check::checker::TyId,
80    tys: &bynk_check::checker::Types,
81    method: &str,
82) -> Option<String> {
83    bynk_check::kernel_methods::methods_for(ty, tys)
84        .iter()
85        .find(|m| m.name == method)
86        .map(|m| m.signature.to_string())
87}
88
89/// Scan back for the `(` that is open at the cursor. A depth-0 `[` or `{` means
90/// the cursor sits in a type-argument list / list literal / block, not a call.
91fn innermost_unclosed_paren(prefix: &str) -> Option<usize> {
92    let b = prefix.as_bytes();
93    let mut depth = 0i32;
94    for i in (0..b.len()).rev() {
95        match b[i] {
96            b')' | b']' | b'}' => depth += 1,
97            b'(' => {
98                if depth == 0 {
99                    return Some(i);
100                }
101                depth -= 1;
102            }
103            b'[' | b'{' => {
104                if depth == 0 {
105                    return None;
106                }
107                depth -= 1;
108            }
109            _ => {}
110        }
111    }
112    None
113}
114
115/// Top-level (bracket-depth-0) commas in `s`.
116fn top_level_commas(s: &str) -> usize {
117    let mut depth = 0i32;
118    let mut n = 0;
119    for c in s.chars() {
120        match c {
121            '(' | '[' | '{' => depth += 1,
122            ')' | ']' | '}' => depth -= 1,
123            ',' if depth == 0 => n += 1,
124            _ => {}
125        }
126    }
127    n
128}
129
130/// The callee immediately before the `(` — a bare `name` or `Recv.member`.
131fn callee_before(s: &str) -> Option<String> {
132    let s = s.trim_end();
133    // Advance past the matched char by its UTF-8 length; a multi-byte
134    // non-identifier char (`"`, `€`, `—`, …) would make `i + 1` land
135    // mid-codepoint and panic the slice.
136    let start = s
137        .char_indices()
138        .rfind(|&(_, c)| !(c.is_alphanumeric() || c == '_' || c == '.'))
139        .map_or(0, |(i, c)| i + c.len_utf8());
140    let callee = &s[start..];
141    if callee.is_empty() || callee.starts_with('.') || callee.ends_with('.') {
142        return None;
143    }
144    Some(callee.to_string())
145}
146
147/// Render the signature *label* for a name callee — `name(p: T, …) -> R`.
148/// `None` if the callee can't be resolved (or is a value receiver — slice 2).
149pub fn resolve_label(
150    callee: &str,
151    doc_text: &str,
152    files: Option<&HashMap<PathBuf, String>>,
153) -> Option<String> {
154    if let Some((recv, member)) = callee.rsplit_once('.') {
155        // Built-in type statics — already display-ready signature strings.
156        if let Some((_, statics)) = BUILTIN_STATICS.iter().find(|(n, _)| *n == recv)
157            && let Some((_, sig)) = statics.iter().find(|(n, _)| *n == member)
158        {
159            return Some((*sig).to_string());
160        }
161        // A refined/opaque type's `of`/`unsafe`, or a capability op.
162        return resolve_qualified(recv, member, doc_text, files);
163    }
164    // Built-in constructors.
165    match callee {
166        "Ok" => return Some("Ok(value: T) -> Result[T, E]".to_string()),
167        "Err" => return Some("Err(error: E) -> Result[T, E]".to_string()),
168        "Some" => return Some("Some(value: T) -> Option[T]".to_string()),
169        _ => {}
170    }
171    // A free function.
172    let mut found = None;
173    for_each_unit(doc_text, files, |unit| {
174        if found.is_some() {
175            return;
176        }
177        let items = match unit {
178            SourceUnit::Commons(c) => &c.items,
179            SourceUnit::Context(c) => &c.items,
180            SourceUnit::Adapter(a) => &a.items,
181            _ => return,
182        };
183        for item in items {
184            if let CommonsItem::Fn(f) = item
185                && let FnName::Free(id) = &f.name
186                && id.name == callee
187            {
188                let params: Vec<String> = f
189                    .params
190                    .iter()
191                    .map(|p| format!("{}: {}", p.name.name, type_ref_str(&p.type_ref)))
192                    .collect();
193                found = Some(format!(
194                    "{callee}({}) -> {}",
195                    params.join(", "),
196                    type_ref_str(&f.return_type)
197                ));
198                return;
199            }
200        }
201    });
202    found
203}
204
205fn resolve_qualified(
206    recv: &str,
207    member: &str,
208    doc_text: &str,
209    files: Option<&HashMap<PathBuf, String>>,
210) -> Option<String> {
211    let mut out = None;
212    for_each_unit(doc_text, files, |unit| {
213        if out.is_some() {
214            return;
215        }
216        let items = match unit {
217            SourceUnit::Commons(c) => &c.items,
218            SourceUnit::Context(c) => &c.items,
219            SourceUnit::Adapter(a) => &a.items,
220            _ => return,
221        };
222        for item in items {
223            match item {
224                // `Type.of` / `Type.unsafe` for a refined/opaque type.
225                CommonsItem::Type(t)
226                    if t.name.name == recv && (member == "of" || member == "unsafe") =>
227                {
228                    let base = match &t.body {
229                        TypeBody::Refined { base, .. } | TypeBody::Opaque { base, .. } => {
230                            base_name(*base)
231                        }
232                        _ => return,
233                    };
234                    out = Some(if member == "of" {
235                        format!("of(value: {base}) -> Result[{recv}, ValidationError]")
236                    } else {
237                        format!("unsafe(value: {base}) -> {recv}")
238                    });
239                    return;
240                }
241                // `Cap.op` — a capability operation.
242                CommonsItem::Capability(c) if c.name.name == recv => {
243                    if let Some(op) = c.ops.iter().find(|o| o.name.name == member) {
244                        let params: Vec<String> = op
245                            .params
246                            .iter()
247                            .map(|p| format!("{}: {}", p.name.name, type_ref_str(&p.type_ref)))
248                            .collect();
249                        // #926: `[T, …]` type parameters on the op itself.
250                        let type_params = if op.type_params.is_empty() {
251                            String::new()
252                        } else {
253                            let names: Vec<&str> = op
254                                .type_params
255                                .iter()
256                                .map(|tp| tp.name.name.as_str())
257                                .collect();
258                            format!("[{}]", names.join(", "))
259                        };
260                        out = Some(format!(
261                            "{member}{type_params}({}) -> {}",
262                            params.join(", "),
263                            type_ref_str(&op.return_type)
264                        ));
265                        return;
266                    }
267                }
268                _ => {}
269            }
270        }
271    });
272    out
273}
274
275fn base_name(b: BaseType) -> &'static str {
276    match b {
277        BaseType::Int => "Int",
278        BaseType::Float => "Float",
279        BaseType::String => "String",
280        BaseType::Bool => "Bool",
281        BaseType::Duration => "Duration",
282        BaseType::Instant => "Instant",
283        BaseType::Bytes => "Bytes",
284    }
285}
286
287/// The byte ranges of each top-level parameter within a signature `label`
288/// (`name(p0, p1, …) -> R`) — for the LSP `ParameterInformation` offsets.
289pub fn param_ranges(label: &str) -> Vec<(usize, usize)> {
290    let Some(open) = label.find('(') else {
291        return Vec::new();
292    };
293    let mut ranges = Vec::new();
294    let mut depth = 0i32;
295    let mut seg_start = open + 1;
296    let bytes = label.as_bytes();
297    let mut i = open;
298    while i < bytes.len() {
299        match bytes[i] {
300            b'(' | b'[' | b'{' => depth += 1,
301            b')' | b']' | b'}' => {
302                depth -= 1;
303                if depth == 0 {
304                    push_trimmed(label, seg_start, i, &mut ranges);
305                    break;
306                }
307            }
308            b',' if depth == 1 => {
309                push_trimmed(label, seg_start, i, &mut ranges);
310                seg_start = i + 1;
311            }
312            _ => {}
313        }
314        i += 1;
315    }
316    ranges
317}
318
319fn push_trimmed(label: &str, start: usize, end: usize, out: &mut Vec<(usize, usize)>) {
320    let seg = &label[start..end];
321    let trimmed = seg.trim();
322    if trimmed.is_empty() {
323        return;
324    }
325    let s = start + (seg.len() - seg.trim_start().len());
326    out.push((s, s + trimmed.len()));
327}
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332
333    #[test]
334    fn call_context_finds_callee_and_active_param() {
335        let t = "  let x = f(a, b";
336        let ctx = call_context(t, t.len()).unwrap();
337        assert_eq!(ctx.callee, "f");
338        assert_eq!(ctx.active_param, 1); // after one comma
339    }
340
341    #[test]
342    fn innermost_call_wins_in_nested_calls() {
343        let t = "  outer(g(x";
344        let ctx = call_context(t, t.len()).unwrap();
345        assert_eq!(ctx.callee, "g");
346        assert_eq!(ctx.active_param, 0);
347        // back out to the outer call's second arg
348        let t2 = "  outer(g(x), ";
349        let ctx2 = call_context(t2, t2.len()).unwrap();
350        assert_eq!(ctx2.callee, "outer");
351        assert_eq!(ctx2.active_param, 1);
352    }
353
354    #[test]
355    fn commas_inside_nested_brackets_dont_count() {
356        let t = "  f(g(a, b), ";
357        assert_eq!(call_context(t, t.len()).unwrap().active_param, 1);
358    }
359
360    #[test]
361    fn qualified_callee_and_no_call_context() {
362        let t = "    Clock.now(";
363        assert_eq!(call_context(t, t.len()).unwrap().callee, "Clock.now");
364        assert!(call_context("  let x = 1", 11).is_none()); // not in a call
365        assert!(call_context("  xs[", 4).is_none()); // a list index, not a call
366    }
367
368    #[test]
369    fn callee_extraction_survives_a_multibyte_char_before_the_callee() {
370        // A multi-byte non-identifier char before the callee used to make the
371        // `i + 1` byte offset land mid-codepoint → panic on slice (#715). `(`
372        // is a trigger char, so this fired on signature help inside a string.
373        assert_eq!(callee_before("\"Foo.bar"), Some("Foo.bar".to_string()));
374        assert_eq!(callee_before("€f"), Some("f".to_string()));
375        // The whole path: a call context whose prefix opens a string literal.
376        let t = "  \"€greet(";
377        let _ = call_context(t, t.len());
378    }
379
380    #[test]
381    fn builtin_static_and_constructor_labels() {
382        assert_eq!(
383            resolve_label("Int.parse", "context a.b\n", None).as_deref(),
384            Some("parse(s: String) -> Option[Int]")
385        );
386        assert!(
387            resolve_label("Ok", "context a.b\n", None)
388                .unwrap()
389                .starts_with("Ok(value")
390        );
391    }
392
393    #[test]
394    fn free_fn_and_capability_op_and_refined_labels() {
395        let doc = "commons m {\n  fn add(a: Int, b: Int) -> Int { a }\n}\n";
396        assert_eq!(
397            resolve_label("add", doc, None).as_deref(),
398            Some("add(a: Int, b: Int) -> Int")
399        );
400        let cap = "context a.b\n  capability Timer { fn after(label: String) -> Effect[Int] }\n";
401        assert_eq!(
402            resolve_label("Timer.after", cap, None).as_deref(),
403            Some("after(label: String) -> Effect[Int]")
404        );
405        let refined = "commons m {\n  type Email = String where NonEmpty\n}\n";
406        assert_eq!(
407            resolve_label("Email.of", refined, None).as_deref(),
408            Some("of(value: String) -> Result[Email, ValidationError]")
409        );
410    }
411
412    #[test]
413    fn value_receiver_callee_detection_and_rewrite() {
414        assert_eq!(value_receiver_method("xs.fold"), Some(("xs", "fold")));
415        assert_eq!(value_receiver_method("Int.parse"), None); // uppercase = name callee
416        assert_eq!(value_receiver_method("a.b.fold"), None); // multi-segment
417        assert_eq!(value_receiver_method("bar"), None); // no receiver
418
419        let text = "  let r = xs.fold(0, ";
420        let open = text.find('(').unwrap();
421        let (rw, off) = value_receiver_rewrite(text, "xs.fold", open, text.len()).unwrap();
422        assert_eq!(rw, "  let r = xs", "the `.fold(0, ` is dropped");
423        assert_eq!(&text[off..=off], "s", "offset lands inside `xs`");
424    }
425
426    #[test]
427    fn kernel_method_signature_lookup() {
428        use bynk_check::checker::{Ty, Types};
429        use bynk_syntax::ast::BaseType;
430        let tys = &Types::new();
431        let list = tys.intern(Ty::List(tys.intern(Ty::Base(BaseType::Int))));
432        assert!(
433            kernel_method_signature(list, tys, "fold")
434                .unwrap()
435                .starts_with("fold(")
436        );
437        let string = tys.intern(Ty::Base(BaseType::String));
438        assert!(
439            kernel_method_signature(string, tys, "split")
440                .unwrap()
441                .starts_with("split(")
442        );
443        assert!(kernel_method_signature(string, tys, "nope").is_none());
444    }
445
446    #[test]
447    fn param_ranges_split_top_level_only() {
448        let label = "fold(init: U, step: (U, T) -> U) -> U";
449        let r = param_ranges(label);
450        assert_eq!(r.len(), 2, "two params, not split inside (U, T): {r:?}");
451        assert_eq!(&label[r[0].0..r[0].1], "init: U");
452        assert_eq!(&label[r[1].0..r[1].1], "step: (U, T) -> U");
453    }
454}