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::{BTreeSet, 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    resolve_qualified_from(recv, member, doc_text, files, &mut BTreeSet::new())
212}
213
214/// [`resolve_qualified`], with the derived capabilities already followed
215/// (S4a, #1966). Signature help reads unchecked source, so a derivation cycle
216/// (`capability A = B`, `capability B = A`) can be on screen: each receiver
217/// is followed once.
218fn resolve_qualified_from(
219    recv: &str,
220    member: &str,
221    doc_text: &str,
222    files: Option<&HashMap<PathBuf, String>>,
223    followed: &mut BTreeSet<String>,
224) -> Option<String> {
225    followed.insert(recv.to_string());
226    let mut out = None;
227    // S4a (#1966): a derived capability's (`capability Sessions = Kv`) base,
228    // whose operations it has.
229    let mut derived_base: Option<String> = None;
230    for_each_unit(doc_text, files, |unit| {
231        if out.is_some() {
232            return;
233        }
234        let items = match unit {
235            SourceUnit::Commons(c) => &c.items,
236            SourceUnit::Context(c) => &c.items,
237            SourceUnit::Adapter(a) => &a.items,
238            _ => return,
239        };
240        for item in items {
241            match item {
242                // `Type.of` / `Type.unsafe` for a refined/opaque type.
243                CommonsItem::Type(t)
244                    if t.name.name == recv && (member == "of" || member == "unsafe") =>
245                {
246                    let base = match &t.body {
247                        TypeBody::Refined { base, .. } | TypeBody::Opaque { base, .. } => {
248                            base_name(*base)
249                        }
250                        _ => return,
251                    };
252                    out = Some(if member == "of" {
253                        format!("of(value: {base}) -> Result[{recv}, ValidationError]")
254                    } else {
255                        format!("unsafe(value: {base}) -> {recv}")
256                    });
257                    return;
258                }
259                CommonsItem::Capability(c) if c.name.name == recv && c.derived.is_some() => {
260                    derived_base = c.derived.as_ref().map(|d| d.base.name.clone());
261                }
262                // `Cap.op` — a capability operation.
263                CommonsItem::Capability(c) if c.name.name == recv => {
264                    if let Some(op) = c.ops.iter().find(|o| o.name.name == member) {
265                        let params: Vec<String> = op
266                            .params
267                            .iter()
268                            .map(|p| format!("{}: {}", p.name.name, type_ref_str(&p.type_ref)))
269                            .collect();
270                        // #926: `[T, …]` type parameters on the op itself.
271                        let type_params = if op.type_params.is_empty() {
272                            String::new()
273                        } else {
274                            let names: Vec<&str> = op
275                                .type_params
276                                .iter()
277                                .map(|tp| tp.name.name.as_str())
278                                .collect();
279                            format!("[{}]", names.join(", "))
280                        };
281                        out = Some(format!(
282                            "{member}{type_params}({}) -> {}",
283                            params.join(", "),
284                            type_ref_str(&op.return_type)
285                        ));
286                        return;
287                    }
288                }
289                _ => {}
290            }
291        }
292    });
293    match (out, derived_base) {
294        (None, Some(base)) if !followed.contains(&base) => {
295            resolve_qualified_from(&base, member, doc_text, files, followed)
296        }
297        (out, _) => out,
298    }
299}
300
301fn base_name(b: BaseType) -> &'static str {
302    match b {
303        BaseType::Int => "Int",
304        BaseType::Float => "Float",
305        BaseType::String => "String",
306        BaseType::Bool => "Bool",
307        BaseType::Duration => "Duration",
308        BaseType::Instant => "Instant",
309        BaseType::Bytes => "Bytes",
310    }
311}
312
313/// The byte ranges of each top-level parameter within a signature `label`
314/// (`name(p0, p1, …) -> R`) — for the LSP `ParameterInformation` offsets.
315pub fn param_ranges(label: &str) -> Vec<(usize, usize)> {
316    let Some(open) = label.find('(') else {
317        return Vec::new();
318    };
319    let mut ranges = Vec::new();
320    let mut depth = 0i32;
321    let mut seg_start = open + 1;
322    let bytes = label.as_bytes();
323    let mut i = open;
324    while i < bytes.len() {
325        match bytes[i] {
326            b'(' | b'[' | b'{' => depth += 1,
327            b')' | b']' | b'}' => {
328                depth -= 1;
329                if depth == 0 {
330                    push_trimmed(label, seg_start, i, &mut ranges);
331                    break;
332                }
333            }
334            b',' if depth == 1 => {
335                push_trimmed(label, seg_start, i, &mut ranges);
336                seg_start = i + 1;
337            }
338            _ => {}
339        }
340        i += 1;
341    }
342    ranges
343}
344
345fn push_trimmed(label: &str, start: usize, end: usize, out: &mut Vec<(usize, usize)>) {
346    let seg = &label[start..end];
347    let trimmed = seg.trim();
348    if trimmed.is_empty() {
349        return;
350    }
351    let s = start + (seg.len() - seg.trim_start().len());
352    out.push((s, s + trimmed.len()));
353}
354
355#[cfg(test)]
356mod tests {
357    use super::*;
358
359    #[test]
360    fn call_context_finds_callee_and_active_param() {
361        let t = "  let x = f(a, b";
362        let ctx = call_context(t, t.len()).unwrap();
363        assert_eq!(ctx.callee, "f");
364        assert_eq!(ctx.active_param, 1); // after one comma
365    }
366
367    #[test]
368    fn innermost_call_wins_in_nested_calls() {
369        let t = "  outer(g(x";
370        let ctx = call_context(t, t.len()).unwrap();
371        assert_eq!(ctx.callee, "g");
372        assert_eq!(ctx.active_param, 0);
373        // back out to the outer call's second arg
374        let t2 = "  outer(g(x), ";
375        let ctx2 = call_context(t2, t2.len()).unwrap();
376        assert_eq!(ctx2.callee, "outer");
377        assert_eq!(ctx2.active_param, 1);
378    }
379
380    #[test]
381    fn commas_inside_nested_brackets_dont_count() {
382        let t = "  f(g(a, b), ";
383        assert_eq!(call_context(t, t.len()).unwrap().active_param, 1);
384    }
385
386    #[test]
387    fn qualified_callee_and_no_call_context() {
388        let t = "    Clock.now(";
389        assert_eq!(call_context(t, t.len()).unwrap().callee, "Clock.now");
390        assert!(call_context("  let x = 1", 11).is_none()); // not in a call
391        assert!(call_context("  xs[", 4).is_none()); // a list index, not a call
392    }
393
394    #[test]
395    fn callee_extraction_survives_a_multibyte_char_before_the_callee() {
396        // A multi-byte non-identifier char before the callee used to make the
397        // `i + 1` byte offset land mid-codepoint → panic on slice (#715). `(`
398        // is a trigger char, so this fired on signature help inside a string.
399        assert_eq!(callee_before("\"Foo.bar"), Some("Foo.bar".to_string()));
400        assert_eq!(callee_before("€f"), Some("f".to_string()));
401        // The whole path: a call context whose prefix opens a string literal.
402        let t = "  \"€greet(";
403        let _ = call_context(t, t.len());
404    }
405
406    /// S4a (#1966): a derived capability's operation has its base's signature.
407    #[test]
408    fn a_derived_capability_op_has_its_bases_signature() {
409        const DERIVED_DOC: &str =
410            "context a\n\nconsumes bynk.cloudflare { Kv }\n\ncapability Sessions = Kv\n";
411        assert_eq!(
412            resolve_label("Sessions.put", DERIVED_DOC, None).as_deref(),
413            Some("put(key: String, value: String) -> Effect[()]")
414        );
415    }
416
417    /// Review of #1968: a derivation cycle in unchecked source resolves
418    /// nothing rather than recursing without end.
419    #[test]
420    fn a_derivation_cycle_has_no_signature() {
421        let doc = "context a\n\ncapability A = B\ncapability B = A\n";
422        assert_eq!(resolve_label("A.put", doc, None), None);
423    }
424
425    #[test]
426    fn builtin_static_and_constructor_labels() {
427        assert_eq!(
428            resolve_label("Int.parse", "context a.b\n", None).as_deref(),
429            Some("parse(s: String) -> Option[Int]")
430        );
431        assert!(
432            resolve_label("Ok", "context a.b\n", None)
433                .unwrap()
434                .starts_with("Ok(value")
435        );
436    }
437
438    #[test]
439    fn free_fn_and_capability_op_and_refined_labels() {
440        let doc = "commons m {\n  fn add(a: Int, b: Int) -> Int { a }\n}\n";
441        assert_eq!(
442            resolve_label("add", doc, None).as_deref(),
443            Some("add(a: Int, b: Int) -> Int")
444        );
445        let cap = "context a.b\n  capability Timer { fn after(label: String) -> Effect[Int] }\n";
446        assert_eq!(
447            resolve_label("Timer.after", cap, None).as_deref(),
448            Some("after(label: String) -> Effect[Int]")
449        );
450        let refined = "commons m {\n  type Email = String where NonEmpty\n}\n";
451        assert_eq!(
452            resolve_label("Email.of", refined, None).as_deref(),
453            Some("of(value: String) -> Result[Email, ValidationError]")
454        );
455    }
456
457    #[test]
458    fn value_receiver_callee_detection_and_rewrite() {
459        assert_eq!(value_receiver_method("xs.fold"), Some(("xs", "fold")));
460        assert_eq!(value_receiver_method("Int.parse"), None); // uppercase = name callee
461        assert_eq!(value_receiver_method("a.b.fold"), None); // multi-segment
462        assert_eq!(value_receiver_method("bar"), None); // no receiver
463
464        let text = "  let r = xs.fold(0, ";
465        let open = text.find('(').unwrap();
466        let (rw, off) = value_receiver_rewrite(text, "xs.fold", open, text.len()).unwrap();
467        assert_eq!(rw, "  let r = xs", "the `.fold(0, ` is dropped");
468        assert_eq!(&text[off..=off], "s", "offset lands inside `xs`");
469    }
470
471    #[test]
472    fn kernel_method_signature_lookup() {
473        use bynk_check::checker::{Ty, Types};
474        use bynk_syntax::ast::BaseType;
475        let tys = &Types::new();
476        let list = tys.intern(Ty::List(tys.intern(Ty::Base(BaseType::Int))));
477        assert!(
478            kernel_method_signature(list, tys, "fold")
479                .unwrap()
480                .starts_with("fold(")
481        );
482        let string = tys.intern(Ty::Base(BaseType::String));
483        assert!(
484            kernel_method_signature(string, tys, "split")
485                .unwrap()
486                .starts_with("split(")
487        );
488        assert!(kernel_method_signature(string, tys, "nope").is_none());
489    }
490
491    #[test]
492    fn param_ranges_split_top_level_only() {
493        let label = "fold(init: U, step: (U, T) -> U) -> U";
494        let r = param_ranges(label);
495        assert_eq!(r.len(), 2, "two params, not split inside (U, T): {r:?}");
496        assert_eq!(&label[r[0].0..r[0].1], "init: U");
497        assert_eq!(&label[r[1].0..r[1].1], "step: (U, T) -> U");
498    }
499}