1use 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
18pub 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
46pub 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
66pub(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
84pub(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
235pub struct CrossFileSymbol {
240 pub uri: Url,
241 pub span: Span,
242 pub source: String,
243}
244
245pub 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
275pub 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
300pub(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 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(¶ms[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 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 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, ¤t, "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 assert!(find_declaration_cross_file(&root, ¤t, "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, ¤t, "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, ¤t, "DoesNotExist").is_none());
470 assert!(describe_symbol_cross_file(&root, ¤t, "DoesNotExist").is_none());
471 }
472
473 #[test]
474 fn first_party_symbols_describe_their_signature_and_doc() {
475 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 let clock = describe_firstparty_symbol("Clock").expect("`bynk`-surface `Clock`");
489 assert!(
490 clock.contains("wall-clock"),
491 "capability doc surfaced: {clock}"
492 );
493 assert!(describe_firstparty_symbol("DoesNotExist").is_none());
495 }
496
497 #[test]
498 fn unit_reference_spans_finds_uses_and_consumes_targets() {
499 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 let (_, span) = spans.iter().find(|(n, _)| n == "billing.charge").unwrap();
508 assert_eq!(&src[span.start..span.end], "billing.charge");
509 }
510}