Skip to main content

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