Skip to main content

bynkc_lsp/
symbols.rs

1//! Symbol lookups for hover and go-to-definition.
2//!
3//! Single-file lookups walk the parsed AST. Cross-file lookups (v1.1; LSP
4//! spec §3.4 cross-file requirement) iterate the project's `.bynk` sources
5//! to find a declaration in any unit the user might be referencing — used
6//! when the open file lacks the symbol the user clicked on (typically
7//! because the name was imported via `uses` or made available via
8//! `consumes`).
9
10use std::path::{Path, PathBuf};
11
12use bynk_syntax::ast::*;
13use bynk_syntax::lexer::tokenize;
14use bynk_syntax::parser::parse_unit_with_recovery;
15use bynk_syntax::span::Span;
16use tower_lsp::lsp_types::Url;
17
18/// Return the source span of the declaration named `name` in the given
19/// source text. Returns `None` if no declaration matches.
20pub fn find_declaration_span(source: &str, name: &str) -> Option<Span> {
21    let tokens = tokenize(source).ok()?;
22    let (unit, _errs) = parse_unit_with_recovery(&tokens, source);
23    let unit = unit?;
24    let items: &[CommonsItem] = match &unit {
25        SourceUnit::Commons(c) => &c.items,
26        SourceUnit::Context(c) => &c.items,
27        SourceUnit::Adapter(a) => &a.items,
28        SourceUnit::Suite(_) | SourceUnit::Integration(_) => &[],
29    };
30    for item in items {
31        match item {
32            CommonsItem::Type(t) if t.name.name == name => return Some(t.name.span),
33            CommonsItem::Fn(f) if f.name.ident().name == name => return Some(f.name.ident().span),
34            CommonsItem::Capability(c) if c.name.name == name => return Some(c.name.span),
35            CommonsItem::Service(s) if s.name.name == name => return Some(s.name.span),
36            CommonsItem::Agent(a) if a.name.name == name => return Some(a.name.span),
37            CommonsItem::Provider(p) if p.provider_name.name == name => {
38                return Some(p.provider_name.span);
39            }
40            _ => {}
41        }
42    }
43    None
44}
45
46/// Build a Markdown summary of a named declaration suitable for an LSP
47/// hover response. Returns `None` if no declaration matches.
48pub fn describe_symbol(source: &str, name: &str) -> Option<String> {
49    let tokens = tokenize(source).ok()?;
50    let (unit, _errs) = parse_unit_with_recovery(&tokens, source);
51    let unit = unit?;
52    let items: &[CommonsItem] = match &unit {
53        SourceUnit::Commons(c) => &c.items,
54        SourceUnit::Context(c) => &c.items,
55        SourceUnit::Adapter(a) => &a.items,
56        SourceUnit::Suite(_) | SourceUnit::Integration(_) => &[],
57    };
58    for item in items {
59        if let Some(summary) = describe_item(item, name) {
60            return Some(summary);
61        }
62    }
63    None
64}
65
66/// Describe a symbol declared in the embedded first-party sources — the `bynk`
67/// and `bynk.cloudflare` adapters and the `bynk.list`/`bynk.map`/`bynk.string`
68/// stdlib. Hover and completion-doc resolution otherwise walk only the project's
69/// files (`walk_bynk_files`), so stdlib/surface symbols had no surfaced signature
70/// or doc; this is the fallback after the project scan. Any `---` doc block on a
71/// first-party declaration rides along (via `describe_fn`/`describe_type`/…),
72/// once the sources carry one.
73pub(crate) fn describe_firstparty_symbol(name: &str) -> Option<String> {
74    const SOURCES: &[&str] = &[
75        bynk_check::firstparty::BYNK_ADAPTER_SRC,
76        bynk_check::firstparty::CLOUDFLARE_ADAPTER_SRC,
77        bynk_check::firstparty::BYNK_LIST_SRC,
78        bynk_check::firstparty::BYNK_MAP_SRC,
79        bynk_check::firstparty::BYNK_STRING_SRC,
80    ];
81    SOURCES.iter().find_map(|src| describe_symbol(src, name))
82}
83
84/// Slice 6b: the `(unit name, name span)` of every `uses`/`consumes` target in
85/// the source — the clickable ranges for document links. The link's target file
86/// is resolved by the handler through the unit→source map (ADR 0095); this only
87/// finds the spans, so it works on the live buffer regardless of the map.
88pub(crate) fn unit_reference_spans(source: &str) -> Vec<(String, Span)> {
89    let Ok(tokens) = tokenize(source) else {
90        return Vec::new();
91    };
92    let (Some(unit), _) = parse_unit_with_recovery(&tokens, source) else {
93        return Vec::new();
94    };
95    let (uses, consumes): (&[UsesDecl], &[ConsumesDecl]) = match &unit {
96        SourceUnit::Commons(c) => (&c.uses, &[]),
97        SourceUnit::Context(c) => (&c.uses, &c.consumes),
98        SourceUnit::Adapter(a) => (&a.uses, &a.consumes),
99        SourceUnit::Suite(_) | SourceUnit::Integration(_) => (&[], &[]),
100    };
101    let mut out: Vec<(String, Span)> = Vec::new();
102    for u in uses {
103        out.push((u.target.joined(), u.target.span));
104    }
105    for c in consumes {
106        out.push((c.target.joined(), c.target.span));
107    }
108    out
109}
110
111fn describe_item(item: &CommonsItem, name: &str) -> Option<String> {
112    match item {
113        CommonsItem::Type(t) if t.name.name == name => Some(describe_type(t)),
114        CommonsItem::Fn(f) if f.name.ident().name == name => Some(describe_fn(f)),
115        CommonsItem::Capability(c) if c.name.name == name => Some(describe_capability(c)),
116        CommonsItem::Service(s) if s.name.name == name => Some(describe_service(s)),
117        CommonsItem::Agent(a) if a.name.name == name => Some(describe_agent(a)),
118        CommonsItem::Provider(p) if p.provider_name.name == name => Some(describe_provider(p)),
119        _ => None,
120    }
121}
122
123fn describe_type(t: &TypeDecl) -> String {
124    let mut out = String::new();
125    out.push_str("```bynk\n");
126    let body = match &t.body {
127        TypeBody::Refined { base, .. } => format!("type {} = {}", t.name.name, base.name()),
128        TypeBody::Opaque { base, .. } => format!("type {} = opaque {}", t.name.name, base.name()),
129        TypeBody::Record(_) => format!("type {} = record", t.name.name),
130        TypeBody::Sum(_) => format!("type {} = sum", t.name.name),
131    };
132    out.push_str(&body);
133    out.push_str("\n```\n");
134    if let Some(doc) = &t.documentation {
135        out.push('\n');
136        out.push_str(doc);
137        out.push('\n');
138    }
139    out
140}
141
142fn describe_fn(f: &FnDecl) -> String {
143    let mut out = String::new();
144    out.push_str("```bynk\n");
145    out.push_str("fn ");
146    out.push_str(&f.name.display());
147    out.push('(');
148    let mut parts: Vec<String> = Vec::new();
149    if f.has_self {
150        parts.push("self".into());
151    }
152    for p in &f.params {
153        parts.push(format!("{}: {}", p.name.name, type_ref_str(&p.type_ref)));
154    }
155    out.push_str(&parts.join(", "));
156    out.push_str(") -> ");
157    out.push_str(&type_ref_str(&f.return_type));
158    out.push_str("\n```\n");
159    if let Some(doc) = &f.documentation {
160        out.push('\n');
161        out.push_str(doc);
162        out.push('\n');
163    }
164    out
165}
166
167fn describe_capability(c: &CapabilityDecl) -> String {
168    let mut out = String::new();
169    out.push_str("```bynk\ncapability ");
170    out.push_str(&c.name.name);
171    out.push_str(" {\n");
172    for op in &c.ops {
173        out.push_str("\tfn ");
174        out.push_str(&op.name.name);
175        out.push('(');
176        let parts: Vec<String> = op
177            .params
178            .iter()
179            .map(|p| format!("{}: {}", p.name.name, type_ref_str(&p.type_ref)))
180            .collect();
181        out.push_str(&parts.join(", "));
182        out.push_str(") -> ");
183        out.push_str(&type_ref_str(&op.return_type));
184        out.push('\n');
185    }
186    out.push_str("}\n```\n");
187    if let Some(doc) = &c.documentation {
188        out.push('\n');
189        out.push_str(doc);
190        out.push('\n');
191    }
192    out
193}
194
195fn describe_service(s: &ServiceDecl) -> String {
196    let mut out = format!("```bynk\nservice {}\n```\n", s.name.name);
197    if let Some(doc) = &s.documentation {
198        out.push('\n');
199        out.push_str(doc);
200        out.push('\n');
201    }
202    out.push_str(&format!("\n{} handler(s).", s.handlers.len()));
203    out
204}
205
206fn describe_agent(a: &AgentDecl) -> String {
207    let mut out = format!(
208        "```bynk\nagent {} {{\n\tkey {}: {}\n\t{} store field(s)\n}}\n```\n",
209        a.name.name,
210        a.key_name.name,
211        type_ref_str(&a.key_type),
212        a.store_fields.len(),
213    );
214    if let Some(doc) = &a.documentation {
215        out.push('\n');
216        out.push_str(doc);
217        out.push('\n');
218    }
219    out
220}
221
222fn describe_provider(p: &ProviderDecl) -> String {
223    let mut out = format!(
224        "```bynk\nprovides {} = {}\n```\n",
225        p.capability.name, p.provider_name.name
226    );
227    if let Some(doc) = &p.documentation {
228        out.push('\n');
229        out.push_str(doc);
230        out.push('\n');
231    }
232    out
233}
234
235/// A cross-file declaration lookup result: the URI of the file containing
236/// the declaration, the declaration's source span, and the full source
237/// text of that file (returned because callers need it to convert the
238/// span to an LSP range and to build hover content).
239pub struct CrossFileSymbol {
240    pub uri: Url,
241    pub span: Span,
242    pub source: String,
243}
244
245/// Find `name`'s declaration in any project file other than `current_uri`.
246/// Walks `src_root` recursively, parses each `.bynk` file with recovery,
247/// and returns the first hit. Returns `None` if the name is not found
248/// anywhere in the project.
249///
250/// Caller is responsible for trying the open file's local symbol table
251/// first; this function intentionally skips `current_uri` so the local
252/// path remains the fast path.
253pub fn find_declaration_cross_file(
254    src_root: &Path,
255    current_uri: &Url,
256    name: &str,
257) -> Option<CrossFileSymbol> {
258    for path in walk_bynk_files(src_root) {
259        let Ok(uri) = Url::from_file_path(&path) else {
260            continue;
261        };
262        if &uri == current_uri {
263            continue;
264        }
265        let Ok(source) = std::fs::read_to_string(&path) else {
266            continue;
267        };
268        if let Some(span) = find_declaration_span(&source, name) {
269            return Some(CrossFileSymbol { uri, span, source });
270        }
271    }
272    None
273}
274
275/// Markdown hover content for `name` from any project file other than
276/// `current_uri`, plus the URI of the file that contributed it. Returns
277/// `None` if the name is not declared anywhere in the project.
278pub fn describe_symbol_cross_file(
279    src_root: &Path,
280    current_uri: &Url,
281    name: &str,
282) -> Option<(Url, String)> {
283    for path in walk_bynk_files(src_root) {
284        let Ok(uri) = Url::from_file_path(&path) else {
285            continue;
286        };
287        if &uri == current_uri {
288            continue;
289        }
290        let Ok(source) = std::fs::read_to_string(&path) else {
291            continue;
292        };
293        if let Some(desc) = describe_symbol(&source, name) {
294            return Some((uri, desc));
295        }
296    }
297    None
298}
299
300/// Recursively collect every `.bynk` file under `root`. Returns an empty
301/// vector if the root is missing or unreadable.
302pub(crate) fn walk_bynk_files(root: &Path) -> Vec<PathBuf> {
303    let mut out = Vec::new();
304    let mut stack = vec![root.to_path_buf()];
305    while let Some(dir) = stack.pop() {
306        let Ok(rd) = std::fs::read_dir(&dir) else {
307            continue;
308        };
309        for entry in rd.flatten() {
310            let p = entry.path();
311            if p.is_dir() {
312                stack.push(p);
313            } else if p.extension().and_then(|e| e.to_str()) == Some("bynk") {
314                out.push(p);
315            }
316        }
317    }
318    out.sort();
319    out
320}
321
322pub(crate) fn type_ref_str(t: &TypeRef) -> String {
323    match t {
324        // v0.20a: function types render in Bynk surface syntax.
325        TypeRef::Fn(params, ret, _) => {
326            let lhs = match params.len() {
327                0 => "()".to_string(),
328                1 if !matches!(params[0], TypeRef::Fn(..)) => type_ref_str(&params[0]),
329                _ => format!(
330                    "({})",
331                    params
332                        .iter()
333                        .map(type_ref_str)
334                        .collect::<Vec<_>>()
335                        .join(", ")
336                ),
337            };
338            format!("{lhs} -> {}", type_ref_str(ret))
339        }
340        TypeRef::Base(b, _) => b.name().to_string(),
341        TypeRef::Named(id) => id.name.clone(),
342        TypeRef::Result(a, b, _) => format!("Result[{}, {}]", type_ref_str(a), type_ref_str(b)),
343        TypeRef::Option(t, _) => format!("Option[{}]", type_ref_str(t)),
344        TypeRef::Effect(t, _) => format!("Effect[{}]", type_ref_str(t)),
345        TypeRef::HttpResult(t, _) => format!("HttpResult[{}]", type_ref_str(t)),
346        TypeRef::QueueResult(_) => "QueueResult".to_string(),
347        // v0.20b: the built-in collection types.
348        TypeRef::List(t, _) => format!("List[{}]", type_ref_str(t)),
349        TypeRef::Query(t, _) => format!("Query[{}]", type_ref_str(t)),
350        TypeRef::Stream(t, _) => format!("Stream[{}]", type_ref_str(t)),
351        TypeRef::Connection(t, _) => format!("Connection[{}]", type_ref_str(t)),
352        TypeRef::Map(k, v, _) => format!("Map[{}, {}]", type_ref_str(k), type_ref_str(v)),
353        TypeRef::ValidationError(_) => "ValidationError".to_string(),
354        TypeRef::JsonError(_) => "JsonError".to_string(),
355        TypeRef::Unit(_) => "()".to_string(),
356    }
357}
358
359#[cfg(test)]
360mod tests {
361    use super::*;
362    use std::fs;
363
364    /// Build a temp directory unique to the test name, populate it with
365    /// `(relative_path, contents)` files, and return the root path. The
366    /// directory is left behind on the filesystem; callers can clean up
367    /// if they care.
368    fn setup_project(test_name: &str, files: &[(&str, &str)]) -> PathBuf {
369        let root = std::env::temp_dir().join(format!(
370            "bynk-lsp-test-{}-{}",
371            test_name,
372            std::process::id()
373        ));
374        let _ = fs::remove_dir_all(&root);
375        fs::create_dir_all(&root).expect("create test root");
376        for (rel, contents) in files {
377            let p = root.join(rel);
378            if let Some(parent) = p.parent() {
379                fs::create_dir_all(parent).expect("create parent");
380            }
381            fs::write(&p, contents).expect("write file");
382        }
383        root
384    }
385
386    #[test]
387    fn cross_file_definition_resolves_into_sibling_file() {
388        let root = setup_project(
389            "cross_file_definition",
390            &[
391                (
392                    "a.bynk",
393                    "commons demo.a\n\ntype Foo = Int where Positive\n",
394                ),
395                (
396                    "b.bynk",
397                    "commons demo.b\n\nuses demo.a\n\ntype Bar = Int where NonNegative\n",
398                ),
399            ],
400        );
401        let current = Url::from_file_path(root.join("b.bynk")).unwrap();
402        let found = find_declaration_cross_file(&root, &current, "Foo")
403            .expect("Foo should resolve into a.bynk");
404        let expected = Url::from_file_path(root.join("a.bynk")).unwrap();
405        assert_eq!(found.uri, expected);
406        assert!(
407            found.source.contains("type Foo = Int where Positive"),
408            "source returned does not contain Foo declaration"
409        );
410    }
411
412    #[test]
413    fn cross_file_definition_skips_current_file() {
414        let root = setup_project(
415            "cross_file_skip_current",
416            &[(
417                "only.bynk",
418                "commons demo.only\n\ntype Foo = Int where Positive\n",
419            )],
420        );
421        let current = Url::from_file_path(root.join("only.bynk")).unwrap();
422        // The only file containing Foo is current; cross-file must skip it.
423        assert!(find_declaration_cross_file(&root, &current, "Foo").is_none());
424    }
425
426    #[test]
427    fn cross_file_hover_returns_markdown_summary() {
428        let root = setup_project(
429            "cross_file_hover",
430            &[
431                (
432                    "money.bynk",
433                    "commons demo.money\n\n\
434                     ---\n\
435                     Amount in minor units of currency.\n\
436                     ---\n\
437                     type Money = Int where NonNegative\n",
438                ),
439                (
440                    "orders.bynk",
441                    "commons demo.orders\n\nuses demo.money\n\ntype OrderId = Int where Positive\n",
442                ),
443            ],
444        );
445        let current = Url::from_file_path(root.join("orders.bynk")).unwrap();
446        let (other_uri, desc) = describe_symbol_cross_file(&root, &current, "Money")
447            .expect("Money should produce hover content");
448        assert_eq!(
449            other_uri,
450            Url::from_file_path(root.join("money.bynk")).unwrap()
451        );
452        assert!(desc.contains("type Money"));
453        assert!(
454            desc.contains("Amount in minor units"),
455            "hover should include the doc block"
456        );
457    }
458
459    #[test]
460    fn cross_file_returns_none_for_unknown_name() {
461        let root = setup_project(
462            "cross_file_none",
463            &[(
464                "a.bynk",
465                "commons demo.a\n\ntype Foo = Int where Positive\n",
466            )],
467        );
468        let current = Url::from_file_path(root.join("a.bynk")).unwrap();
469        assert!(find_declaration_cross_file(&root, &current, "DoesNotExist").is_none());
470        assert!(describe_symbol_cross_file(&root, &current, "DoesNotExist").is_none());
471    }
472
473    #[test]
474    fn first_party_symbols_describe_their_signature_and_doc() {
475        // Slice 9: stdlib/surface symbols live in the embedded sources, not the
476        // project — the hover/completion-doc fallback finds them there, signature
477        // and `---` doc block alike.
478        let reverse = describe_firstparty_symbol("reverse").expect("`bynk.list.reverse` described");
479        assert!(
480            reverse.contains("reverse") && reverse.contains("List"),
481            "{reverse}"
482        );
483        assert!(
484            reverse.contains("reverse order"),
485            "doc block surfaced: {reverse}"
486        );
487        // The `bynk` adapter surface too (a capability, exercising the adapter path).
488        let clock = describe_firstparty_symbol("Clock").expect("`bynk`-surface `Clock`");
489        assert!(
490            clock.contains("wall-clock"),
491            "capability doc surfaced: {clock}"
492        );
493        // A name in no first-party source yields nothing (the fallback no-ops).
494        assert!(describe_firstparty_symbol("DoesNotExist").is_none());
495    }
496
497    #[test]
498    fn unit_reference_spans_finds_uses_and_consumes_targets() {
499        // Slice 6b: the clickable ranges for document links — `uses`/`consumes`
500        // unit names, with spans covering the name (resolution is the handler's).
501        let src = "context app.main\n  uses billing.charge\n  consumes platform.time\n";
502        let spans = unit_reference_spans(src);
503        let names: Vec<&str> = spans.iter().map(|(n, _)| n.as_str()).collect();
504        assert!(names.contains(&"billing.charge"), "{names:?}");
505        assert!(names.contains(&"platform.time"), "{names:?}");
506        // The span covers exactly the unit name (so the link underlines it).
507        let (_, span) = spans.iter().find(|(n, _)| n == "billing.charge").unwrap();
508        assert_eq!(&src[span.start..span.end], "billing.charge");
509    }
510}