1use std::collections::HashMap;
11use std::path::{Path, PathBuf};
12
13use bynk_syntax::ast::*;
14use bynk_syntax::lexer::tokenize;
15use bynk_syntax::parser::parse_unit_with_recovery;
16use bynk_syntax::span::Span;
17
18pub fn find_declaration_span(source: &str, name: &str) -> Option<Span> {
35 let tokens = tokenize(source).ok()?;
36 let (unit, _errs) = parse_unit_with_recovery(&tokens, source);
37 let unit = unit?;
38 let items: &[CommonsItem] = match &unit {
39 SourceUnit::Commons(c) => &c.items,
40 SourceUnit::Context(c) => &c.items,
41 SourceUnit::Adapter(a) => &a.items,
42 SourceUnit::Suite(_) => &[],
43 };
44 items
45 .iter()
46 .filter_map(CommonsItem::name)
47 .find(|ident| ident.name == name)
48 .map(|ident| ident.span)
49}
50
51pub fn describe_symbol(source: &str, name: &str) -> Option<String> {
54 let tokens = tokenize(source).ok()?;
55 let (unit, _errs) = parse_unit_with_recovery(&tokens, source);
56 let unit = unit?;
57 let items: &[CommonsItem] = match &unit {
58 SourceUnit::Commons(c) => &c.items,
59 SourceUnit::Context(c) => &c.items,
60 SourceUnit::Adapter(a) => &a.items,
61 SourceUnit::Suite(_) => &[],
62 };
63 for item in items {
64 if let Some(summary) = describe_item(item, name) {
65 return Some(summary);
66 }
67 }
68 None
69}
70
71pub fn describe_keyword_at(source: &str, offset: usize) -> Option<&'static str> {
79 let tokens = tokenize(source).ok()?;
80 let word = tokens
81 .iter()
82 .find(|t| t.span.start <= offset && offset < t.span.end)
83 .map(|t| &source[t.span.start..t.span.end])?;
84 bynk_syntax::keywords::KEYWORDS
85 .iter()
86 .find(|k| k.word == word)
87 .map(|k| k.meaning)
88}
89
90pub fn describe_agent_state_at(source: &str, offset: usize) -> Option<String> {
111 let tokens = tokenize(source).ok()?;
112 let (unit, _errs) = parse_unit_with_recovery(&tokens, source);
113 let unit = unit?;
114 let items: &[CommonsItem] = match &unit {
115 SourceUnit::Commons(c) => &c.items,
116 SourceUnit::Context(c) => &c.items,
117 SourceUnit::Adapter(a) => &a.items,
118 SourceUnit::Suite(_) => &[],
119 };
120 for item in items {
121 let CommonsItem::Agent(a) = item else {
122 continue;
123 };
124 let on_key_kw = preceding_ident_span(&tokens, source, a.key_name.span, "key")
127 .is_some_and(|s| span_covers(s, offset));
128 if on_key_kw || span_covers(a.key_name.span, offset) {
129 return Some(key_hover(a));
130 }
131 for f in &a.store_fields {
135 let store_kw = Span {
136 file: f.span.file,
137 start: f.span.start,
138 end: f.span.start + "store".len(),
139 };
140 let on_store_kw = source.get(store_kw.start..store_kw.end) == Some("store")
141 && span_covers(store_kw, offset);
142 if on_store_kw || span_covers(f.name.span, offset) {
143 return Some(store_field_hover(f));
144 }
145 }
146 if !in_state_scope(a, offset) {
148 continue;
149 }
150 let Some((name, name_span)) = ident_at(&tokens, source, offset) else {
151 continue;
152 };
153 if is_dot_preceded(source, name_span.start) {
156 continue;
157 }
158 if name == a.key_name.name {
159 return Some(key_hover(a));
160 }
161 if let Some(f) = a.store_fields.iter().find(|f| f.name.name == name) {
162 return Some(store_field_hover(f));
163 }
164 }
165 None
166}
167
168fn in_state_scope(a: &AgentDecl, offset: usize) -> bool {
175 a.handlers.iter().any(|h| span_covers(h.body.span, offset))
176 || a.invariants
177 .iter()
178 .any(|i| span_covers(i.predicate.span, offset))
179 || a.transitions
180 .iter()
181 .any(|t| span_covers(t.predicate.span, offset))
182}
183
184fn key_hover(a: &AgentDecl) -> String {
186 let sig = format!("key {}: {}", a.key_name.name, type_ref_str(&a.key_type));
187 render_state_hover(&sig, "key")
188}
189
190fn store_field_hover(f: &StoreField) -> String {
192 let mut sig = format!("store {}: {}", f.name.name, store_kind_str(&f.kind));
193 for ann in &f.annotations {
194 sig.push(' ');
195 sig.push_str(&bynk_fmt::annotation_to_string(ann));
196 }
197 render_state_hover(&sig, "store")
198}
199
200pub fn describe_store_op_at(
213 source: &str,
214 offset: usize,
215 locals: &[bynk_check::locals::LocalBinding],
216) -> Option<String> {
217 let tokens = tokenize(source).ok()?;
218 let (unit, _errs) = parse_unit_with_recovery(&tokens, source);
219 let unit = unit?;
220 let items: &[CommonsItem] = match &unit {
221 SourceUnit::Commons(c) => &c.items,
222 SourceUnit::Context(c) => &c.items,
223 SourceUnit::Adapter(a) => &a.items,
224 SourceUnit::Suite(_) => &[],
225 };
226 let (op, op_span) = ident_at(&tokens, source, offset)?;
228 let (recv, recv_start) = receiver_segment_at(source, op_span)?;
229 if is_dot_preceded(source, recv_start) {
233 return None;
234 }
235 if bynk_check::locals::locals_at(locals, recv_start)
238 .iter()
239 .any(|b| b.name == recv)
240 {
241 return None;
242 }
243 for item in items {
244 let CommonsItem::Agent(a) = item else {
245 continue;
246 };
247 if !in_state_scope(a, offset) {
248 continue;
249 }
250 let f = a.store_fields.iter().find(|f| f.name.name == recv)?;
251 let sig = bynk_check::store_ops::ops_for(&f.kind.head.name)
252 .iter()
253 .find(|o| o.name == op)?
254 .signature;
255 return Some(format!(
256 "```bynk\n{sig}\n```\n\nA `{}` store operation on `store {}: {}` — the field's \
257 declared kind grounds the operation's type parameters.",
258 f.kind.head.name,
259 f.name.name,
260 store_kind_str(&f.kind),
261 ));
262 }
263 None
264}
265
266fn ident_at<'a>(
269 tokens: &[bynk_syntax::lexer::Token],
270 source: &'a str,
271 offset: usize,
272) -> Option<(&'a str, Span)> {
273 tokens
274 .iter()
275 .find(|t| t.kind == bynk_syntax::lexer::TokenKind::Ident && span_covers(t.span, offset))
276 .and_then(|t| Some((source.get(t.span.start..t.span.end)?, t.span)))
277}
278
279fn receiver_segment_at(text: &str, member_span: Span) -> Option<(&str, usize)> {
286 let before = text.get(..member_span.start)?.strip_suffix('.')?;
287 crate::completion::ident_ending_at(before, before.len())
288}
289
290pub(crate) fn is_dot_preceded(text: &str, start: usize) -> bool {
293 text[..start].ends_with('.')
294}
295
296pub fn store_field_kind_at(
314 source: &str,
315 recv_end: usize,
316 locals: &[bynk_check::locals::LocalBinding],
317) -> Option<(String, bool)> {
318 let (recv, recv_start) = crate::completion::ident_ending_at(source, recv_end)?;
319 if bynk_check::locals::locals_at(locals, recv_start)
320 .iter()
321 .any(|b| b.name == recv)
322 {
323 return None;
324 }
325 let tokens = tokenize(source).ok()?;
326 let (unit, _errs) = parse_unit_with_recovery(&tokens, source);
327 let unit = unit?;
328 let items: &[CommonsItem] = match &unit {
329 SourceUnit::Commons(c) => &c.items,
330 SourceUnit::Context(c) => &c.items,
331 SourceUnit::Adapter(a) => &a.items,
332 SourceUnit::Suite(_) => &[],
333 };
334 for item in items {
335 let CommonsItem::Agent(a) = item else {
336 continue;
337 };
338 if !in_state_scope(a, recv_end) {
339 continue;
340 }
341 if let Some(f) = a.store_fields.iter().find(|f| f.name.name == recv) {
342 let held = f.kind.head.name == "Map"
343 && f.kind.args.len() == 2
344 && matches!(f.kind.args[1], TypeRef::Connection(..));
345 return Some((f.kind.head.name.clone(), held));
346 }
347 }
348 None
349}
350
351pub fn describe_handler_annotation_at(source: &str, offset: usize) -> Option<String> {
358 let tokens = tokenize(source).ok()?;
359 let (unit, _errs) = parse_unit_with_recovery(&tokens, source);
360 let unit = unit?;
361 let items: &[CommonsItem] = match &unit {
362 SourceUnit::Commons(c) => &c.items,
363 SourceUnit::Context(c) => &c.items,
364 SourceUnit::Adapter(a) => &a.items,
365 SourceUnit::Suite(_) => &[],
366 };
367 for item in items {
368 let handlers: &[Handler] = match item {
369 CommonsItem::Service(s) => &s.handlers,
370 CommonsItem::Agent(a) => &a.handlers,
371 _ => continue,
372 };
373 for h in handlers {
374 for ann in &h.annotations {
375 if span_covers(ann.span, offset) {
376 return Some(render_handler_annotation_hover(ann));
377 }
378 }
379 }
380 }
381 None
382}
383
384pub fn handler_annotation_token_spans(source: &str) -> Vec<Span> {
390 let Ok(tokens) = tokenize(source) else {
391 return Vec::new();
392 };
393 let (unit, _errs) = parse_unit_with_recovery(&tokens, source);
394 let Some(unit) = unit else {
395 return Vec::new();
396 };
397 let items: &[CommonsItem] = match &unit {
398 SourceUnit::Commons(c) => &c.items,
399 SourceUnit::Context(c) => &c.items,
400 SourceUnit::Adapter(a) => &a.items,
401 SourceUnit::Suite(_) => &[],
402 };
403 let mut spans = Vec::new();
404 for item in items {
405 let handlers: &[Handler] = match item {
406 CommonsItem::Service(s) => &s.handlers,
407 CommonsItem::Agent(a) => &a.handlers,
408 _ => continue,
409 };
410 for h in handlers {
411 for ann in &h.annotations {
412 spans.push(Span {
414 file: ann.span.file,
415 start: ann.span.start,
416 end: ann.name.span.end,
417 });
418 for arg in &ann.args {
420 if let Some(label) = &arg.label {
421 spans.push(label.span);
422 }
423 }
424 }
425 }
426 }
427 spans
428}
429
430fn render_handler_annotation_hover(ann: &Annotation) -> String {
435 let sig = bynk_fmt::annotation_to_string(ann);
436 if ann.name.name == "cache" {
437 return format!(
438 "```bynk\n{sig}\n```\n\n\
439 **`@cache`** — cache this `GET` read. Every eligible `GET` already carries a \
440 synthesised weak `ETag` and is answered `304 Not Modified` on a matching \
441 `If-None-Match`; `@cache` adds a `Cache-Control` freshness window on top.\n\n\
442 - **`maxAge`** — the freshness window, a `Duration` (e.g. `5.minutes`), lowered to \
443 `Cache-Control: max-age`.\n\
444 - **`scope`** — `public` or `private` (default `private`; a shared cache stores the \
445 response only when `public`)."
446 );
447 }
448 if ann.name.name == "limit" {
450 return format!(
451 "```bynk\n{sig}\n```\n\n\
452 **`@limit`** — cap the request body size on this `POST`/`PUT`/`PATCH` route. A \
453 request whose body exceeds the `maxBody` byte ceiling is answered `413 Payload Too \
454 Large`, synthesised before the body is read.\n\n\
455 - **`maxBody`** — the maximum request body size in bytes, a positive `Int`."
456 );
457 }
458 format!("```bynk\n{sig}\n```")
459}
460
461fn span_covers(span: Span, offset: usize) -> bool {
463 span.start <= offset && offset < span.end
464}
465
466fn preceding_ident_span(
471 tokens: &[bynk_syntax::lexer::Token],
472 source: &str,
473 name_span: Span,
474 kw: &str,
475) -> Option<Span> {
476 let idx = tokens
477 .iter()
478 .position(|t| t.span.start == name_span.start)?;
479 let prev = tokens.get(idx.checked_sub(1)?)?;
480 (source.get(prev.span.start..prev.span.end) == Some(kw)).then_some(prev.span)
481}
482
483fn render_state_hover(sig: &str, contextual_kw: &str) -> String {
486 let doc = bynk_syntax::keywords::CONTEXTUAL_KEYWORDS
487 .iter()
488 .find(|k| k.word == contextual_kw)
489 .map(|k| k.meaning)
490 .unwrap_or_default();
491 format!("```bynk\n{sig}\n```\n\n{doc}")
492}
493
494pub fn describe_self_at(
504 text: &str,
505 offset: usize,
506 expr_types: &[(Span, bynk_check::checker::TyId)],
507 tys: &bynk_check::checker::Types,
508) -> Option<String> {
509 let tokens = tokenize(text).ok()?;
510 let on_self = tokens.iter().any(|t| {
511 t.span.start <= offset && offset < t.span.end && &text[t.span.start..t.span.end] == "self"
512 });
513 if !on_self {
514 return None;
515 }
516 let ty = bynk_check::expr_types::type_at_offset(expr_types, offset)?;
517 let display = ty.display(tys);
518 let name = display
519 .strip_prefix("__")
520 .and_then(|s| s.strip_suffix("Self"))
521 .unwrap_or(&display);
522 Some(format!("```bynk\nself: {name}\n```"))
523}
524
525pub fn qualified_callee_at(text: &str, ident_span: Span) -> Option<String> {
533 let (recv, _) = receiver_segment_at(text, ident_span)?;
534 if !recv.chars().next()?.is_uppercase() {
535 return None;
536 }
537 let member = text.get(ident_span.start..ident_span.end)?;
538 Some(format!("{recv}.{member}"))
539}
540
541pub fn describe_firstparty_symbol(name: &str) -> Option<String> {
549 bynk_check::firstparty::FIRSTPARTY_SOURCES
552 .iter()
553 .find_map(|(_, src)| describe_symbol(src, name))
554}
555
556pub fn unit_reference_spans(source: &str) -> Vec<(String, Span)> {
561 let Ok(tokens) = tokenize(source) else {
562 return Vec::new();
563 };
564 let (Some(unit), _) = parse_unit_with_recovery(&tokens, source) else {
565 return Vec::new();
566 };
567 let mut out: Vec<(String, Span)> = Vec::new();
568 let (uses, consumes): (&[UsesDecl], &[ConsumesDecl]) = match &unit {
571 SourceUnit::Commons(c) => (&c.uses, &[]),
572 SourceUnit::Context(c) => (&c.uses, &c.consumes),
573 SourceUnit::Adapter(a) => (&a.uses, &a.consumes),
574 SourceUnit::Suite(s) => {
575 out.push((s.target.joined(), s.target.span));
576 (&s.uses, &[])
577 }
578 };
579 for u in uses {
580 out.push((u.target.joined(), u.target.span));
581 }
582 for c in consumes {
583 out.push((c.target.joined(), c.target.span));
584 }
585 out
586}
587
588#[derive(Debug, Clone, PartialEq, Eq)]
595pub struct DocLinkCandidate {
596 pub name: String,
597 pub span: std::ops::Range<usize>,
598 pub display: String,
599}
600
601fn is_doc_link_name(s: &str) -> bool {
605 fn is_ident(part: &str) -> bool {
606 let mut chars = part.chars();
607 matches!(chars.next(), Some(c) if c.is_ascii_alphabetic() || c == '_')
608 && chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
609 }
610 match s.split_once('.') {
611 Some((owner, member)) => is_ident(owner) && is_ident(member),
612 None => is_ident(s),
613 }
614}
615
616fn doc_link_prepass(text: &str) -> (Vec<(usize, usize)>, std::collections::HashSet<String>) {
625 let mut fenced = Vec::new();
626 let mut defined = std::collections::HashSet::new();
627 let mut in_fence = false;
628 let mut fence_start = 0usize;
629 let mut pos = 0usize;
630 loop {
631 let line_end = text[pos..]
632 .find('\n')
633 .map(|i| pos + i + 1)
634 .unwrap_or(text.len());
635 let line = &text[pos..line_end];
636 let trimmed = line.trim_start();
637 if trimmed.starts_with("```") {
638 if in_fence {
639 fenced.push((fence_start, line_end));
640 in_fence = false;
641 } else {
642 fence_start = pos;
643 in_fence = true;
644 }
645 } else if !in_fence {
646 let indent = line.len() - trimmed.len();
649 if indent <= 3
650 && let Some(rest) = trimmed.strip_prefix('[')
651 && let Some(close) = rest.find(']')
652 {
653 let label = &rest[..close];
654 if let Some(after_colon) = rest[close + 1..].strip_prefix(':')
655 && !label.is_empty()
656 && !after_colon.trim().is_empty()
657 {
658 defined.insert(label.to_string());
659 }
660 }
661 }
662 if line_end >= text.len() {
663 break;
664 }
665 pos = line_end;
666 }
667 if in_fence {
668 fenced.push((fence_start, text.len()));
669 }
670 (fenced, defined)
671}
672
673fn scan_bracket_candidate(
679 text: &str,
680 start: usize,
681 defined: &std::collections::HashSet<String>,
682) -> Option<DocLinkCandidate> {
683 let bytes = text.as_bytes();
684 debug_assert_eq!(bytes[start], b'[');
685 if bytes.get(start + 1) == Some(&b'`') {
687 let inner_start = start + 2;
688 let close_tick = text[inner_start..].find('`')? + inner_start;
689 if bytes.get(close_tick + 1) != Some(&b']') {
690 return None;
691 }
692 let name = &text[inner_start..close_tick];
693 if !is_doc_link_name(name) || defined.contains(name) {
694 return None;
695 }
696 return Some(DocLinkCandidate {
697 name: name.to_string(),
698 span: start..close_tick + 2,
699 display: text[start + 1..close_tick + 1].to_string(),
700 });
701 }
702 let inner_start = start + 1;
703 let close = text[inner_start..].find(']')? + inner_start;
704 let inner = &text[inner_start..close];
705 let after = close + 1;
706 match bytes.get(after) {
707 Some(b'(') => None,
709 Some(b':') => None,
712 Some(b'[') => {
714 let label_start = after + 1;
715 let label_close = text[label_start..].find(']')? + label_start;
716 let label = &text[label_start..label_close];
717 if label.is_empty() || !is_doc_link_name(label) || defined.contains(label) {
719 return None;
720 }
721 Some(DocLinkCandidate {
722 name: label.to_string(),
723 span: start..label_close + 1,
724 display: inner.to_string(),
725 })
726 }
727 _ => {
729 if !is_doc_link_name(inner) || defined.contains(inner) {
730 return None;
731 }
732 Some(DocLinkCandidate {
733 name: inner.to_string(),
734 span: start..after,
735 display: inner.to_string(),
736 })
737 }
738 }
739}
740
741pub fn scan_doc_link_candidates(text: &str) -> Vec<DocLinkCandidate> {
746 let (fenced, defined) = doc_link_prepass(text);
747 let in_fenced = |i: usize| fenced.iter().any(|&(s, e)| i >= s && i < e);
748 let bytes = text.as_bytes();
749 let mut out = Vec::new();
750 let mut i = 0usize;
751 while i < bytes.len() {
752 if bytes[i] != b'[' || in_fenced(i) {
753 i += 1;
754 continue;
755 }
756 match scan_bracket_candidate(text, i, &defined) {
757 Some(cand) => {
758 i = cand.span.end;
759 out.push(cand);
760 }
761 None => i += 1,
762 }
763 }
764 out
765}
766
767pub fn doc_link_spans(source: &str) -> Vec<(String, Span)> {
777 let Ok(tokens) = tokenize(source) else {
778 return Vec::new();
779 };
780 let mut out = Vec::new();
781 for t in tokens {
782 if t.kind != bynk_syntax::lexer::TokenKind::DocBlock {
783 continue;
784 }
785 let Some(range) = bynk_syntax::lexer::doc_block_body_range(source, t.span) else {
786 continue;
787 };
788 for cand in scan_doc_link_candidates(&source[range.clone()]) {
789 out.push((
790 cand.name,
791 Span::new(range.start + cand.span.start, range.start + cand.span.end),
792 ));
793 }
794 }
795 out
796}
797
798pub fn own_declaration_name(source: &str) -> Option<(String, Span)> {
803 let tokens = tokenize(source).ok()?;
804 let (Some(unit), _) = parse_unit_with_recovery(&tokens, source) else {
805 return None;
806 };
807 if matches!(unit, SourceUnit::Suite(_)) {
808 return None;
809 }
810 let name = unit.name();
811 Some((name.joined(), name.span))
812}
813
814fn describe_item(item: &CommonsItem, name: &str) -> Option<String> {
815 match item {
816 CommonsItem::Type(t) if t.name.name == name => Some(describe_type(t)),
817 CommonsItem::Fn(f) if matches!(f.name, FnName::Free(_)) && f.name.ident().name == name => {
824 Some(describe_fn(f))
825 }
826 CommonsItem::Capability(c) if c.name.name == name => Some(describe_capability(c)),
827 CommonsItem::Service(s) if s.name.name == name => Some(describe_service(s)),
828 CommonsItem::Agent(a) if a.name.name == name => Some(describe_agent(a)),
829 CommonsItem::Provider(p) if p.provider_name.name == name => Some(describe_provider(p)),
830 CommonsItem::Actor(a) if a.name.name == name => Some(describe_actor(a)),
835 CommonsItem::Messages(m) if m.tag == name => Some(describe_messages(m)),
839 CommonsItem::Event(e) if e.name.name == name => Some(describe_event(e)),
844 CommonsItem::Type(t) => {
851 let (owner, field) = name.rsplit_once('.')?;
852 if t.name.name != owner {
853 return None;
854 }
855 let TypeBody::Record(r) = &t.body else {
856 return None;
857 };
858 r.fields
859 .iter()
860 .find(|f| f.name.name == field)
861 .map(|f| describe_record_field(t, f))
862 }
863 CommonsItem::Event(e) => {
868 let (owner, field) = name.rsplit_once('.')?;
869 if e.name.name != owner {
870 return None;
871 }
872 let synthetic = e.as_type_decl();
873 e.body
874 .fields
875 .iter()
876 .find(|f| f.name.name == field)
877 .map(|f| describe_record_field(&synthetic, f))
878 }
879 CommonsItem::Fn(f) => (f.name.display() == name).then(|| describe_fn(f)),
884 CommonsItem::Capability(c) => {
886 let (owner, op) = name.rsplit_once('.')?;
887 if c.name.name != owner {
888 return None;
889 }
890 c.ops
891 .iter()
892 .find(|o| o.name.name == op)
893 .map(|o| describe_capability_op(c, o))
894 }
895 CommonsItem::Agent(a) => {
899 let (owner, handler) = name.rsplit_once('.')?;
900 if a.name.name != owner {
901 return None;
902 }
903 a.handlers
904 .iter()
905 .find(|h| h.method_name.as_ref().is_some_and(|n| n.name == handler))
906 .map(|h| describe_agent_handler(a, h, handler))
907 }
908 _ => None,
909 }
910}
911
912pub(crate) fn describe_actor(a: &ActorDecl) -> String {
920 let mut out = String::from("```bynk\n");
921 match &a.refinement {
922 Some(r) => out.push_str(&format!(
924 "actor {} = {} where {}",
925 a.name.name,
926 r.base.name,
927 bynk_fmt::expr_to_string(&r.predicate)
928 )),
929 None => {
930 let auth = a.auth.as_ref().map_or("None", |i| i.name.as_str());
932 out.push_str(&format!("actor {} {{ auth = {auth}", a.name.name));
933 if !a.auth_config.is_empty() {
934 let args: Vec<String> = a
935 .auth_config
936 .iter()
937 .map(|arg| match &arg.value {
938 SchemeArgValue::Str(s) => {
943 format!("{} = \"{}\"", arg.key.name, bynk_fmt::escape_string(s))
944 }
945 SchemeArgValue::Int(n) => format!("{} = {n}", arg.key.name),
946 })
947 .collect();
948 out.push_str(&format!("({})", args.join(", ")));
949 }
950 if let Some(id) = &a.identity {
951 out.push_str(&format!(", identity = {}", type_ref_str(id)));
952 }
953 out.push_str(" }");
954 }
955 }
956 out.push_str("\n```\n");
957 if let Some(doc) = &a.documentation {
958 out.push('\n');
959 out.push_str(doc);
960 out.push('\n');
961 }
962 out
963}
964
965fn capability_op_type_params_str(op: &CapabilityOp) -> String {
969 if op.type_params.is_empty() {
970 return String::new();
971 }
972 let names: Vec<&str> = op
973 .type_params
974 .iter()
975 .map(|tp| tp.name.name.as_str())
976 .collect();
977 format!("[{}]", names.join(", "))
978}
979
980pub(crate) fn describe_capability_op(c: &CapabilityDecl, op: &CapabilityOp) -> String {
984 let params: Vec<String> = op
985 .params
986 .iter()
987 .map(|p| format!("{}: {}", p.name.name, type_ref_str(&p.type_ref)))
988 .collect();
989 let mut out = format!(
990 "```bynk\nfn {}{}({}) -> {}\n```\n\nAn operation of capability `{}`.\n",
991 op.name.name,
992 capability_op_type_params_str(op),
993 params.join(", "),
994 type_ref_str(&op.return_type),
995 c.name.name
996 );
997 if let Some(doc) = &op.documentation {
998 out.push('\n');
999 out.push_str(doc);
1000 out.push('\n');
1001 }
1002 out
1003}
1004
1005pub(crate) fn describe_agent_handler(a: &AgentDecl, h: &Handler, handler_name: &str) -> String {
1008 let params: Vec<String> = h
1009 .params
1010 .iter()
1011 .map(|p| format!("{}: {}", p.name.name, type_ref_str(&p.type_ref)))
1012 .collect();
1013 let mut out = format!(
1014 "```bynk\nfn {}({}) -> {}\n```\n\nA handler of agent `{}`.\n",
1015 handler_name,
1016 params.join(", "),
1017 type_ref_str(&h.return_type),
1018 a.name.name
1019 );
1020 if let Some(doc) = &h.documentation {
1021 out.push('\n');
1022 out.push_str(doc);
1023 out.push('\n');
1024 }
1025 out
1026}
1027
1028pub(crate) fn describe_service_handler(s: &ServiceDecl, h: &Handler) -> String {
1037 let params: Vec<String> = h
1038 .params
1039 .iter()
1040 .map(|p| format!("{}: {}", p.name.name, type_ref_str(&p.type_ref)))
1041 .collect();
1042 let mut out = format!(
1043 "```bynk\n{}({}) -> {}\n```\n\nA handler of service `{}`.\n",
1044 handler_line(h),
1045 params.join(", "),
1046 type_ref_str(&h.return_type),
1047 s.name.name
1048 );
1049 if let Some(doc) = &h.documentation {
1050 out.push('\n');
1051 out.push_str(doc);
1052 out.push('\n');
1053 }
1054 out
1055}
1056
1057pub(crate) fn describe_record_field(t: &TypeDecl, f: &RecordField) -> String {
1061 let mut sig = format!("{}: {}", f.name.name, type_ref_str(&f.type_ref));
1062 if let Some(r) = &f.refinement {
1063 sig.push_str(&format!(" where {}", bynk_fmt::refinement_to_string(r)));
1064 }
1065 if let Some(init) = &f.init {
1069 sig.push_str(&format!(" = {}", bynk_fmt::expr_to_string(init)));
1070 }
1071 format!("```bynk\n{sig}\n```\n\nA field of `{}`.", t.name.name)
1072}
1073
1074pub(crate) fn describe_type(t: &TypeDecl) -> String {
1075 let mut out = String::new();
1076 out.push_str("```bynk\n");
1077 let params = if t.type_params.is_empty() {
1079 String::new()
1080 } else {
1081 let names: Vec<&str> = t
1082 .type_params
1083 .iter()
1084 .map(|tp| tp.name.name.as_str())
1085 .collect();
1086 format!("[{}]", names.join(", "))
1087 };
1088 out.push_str(&format!("type {}{} = ", t.name.name, params));
1089 match &t.body {
1090 TypeBody::Refined {
1093 base, refinement, ..
1094 } => {
1095 out.push_str(base.name());
1096 if let Some(r) = refinement {
1097 out.push_str(&format!(" where {}", bynk_fmt::refinement_to_string(r)));
1098 }
1099 }
1100 TypeBody::Opaque {
1101 base, refinement, ..
1102 } => {
1103 out.push_str(&format!("opaque {}", base.name()));
1104 if let Some(r) = refinement {
1105 out.push_str(&format!(" where {}", bynk_fmt::refinement_to_string(r)));
1106 }
1107 }
1108 TypeBody::Record(r) => {
1110 if r.fields.is_empty() {
1111 out.push_str("{}");
1112 } else {
1113 out.push_str("{\n");
1114 for f in &r.fields {
1115 out.push_str(&format!("\t{}: {}", f.name.name, type_ref_str(&f.type_ref)));
1116 if let Some(r) = &f.refinement {
1117 out.push_str(&format!(" where {}", bynk_fmt::refinement_to_string(r)));
1118 }
1119 if let Some(init) = &f.init {
1123 out.push_str(&format!(" = {}", bynk_fmt::expr_to_string(init)));
1124 }
1125 out.push_str(",\n");
1126 }
1127 out.push('}');
1128 }
1129 }
1130 TypeBody::Sum(s) => {
1132 out.push_str("enum {\n");
1133 for v in &s.variants {
1134 out.push_str(&format!("\t{}", v.name.name));
1135 if !v.payload.is_empty() {
1136 let parts: Vec<String> = v
1137 .payload
1138 .iter()
1139 .map(|p| format!("{}: {}", p.name.name, type_ref_str(&p.type_ref)))
1140 .collect();
1141 out.push_str(&format!("({})", parts.join(", ")));
1142 }
1143 out.push_str(",\n");
1144 }
1145 out.push('}');
1146 }
1147 }
1148 out.push_str("\n```\n");
1149 if let Some(doc) = &t.documentation {
1150 out.push('\n');
1151 out.push_str(doc);
1152 out.push('\n');
1153 }
1154 out
1155}
1156
1157fn describe_event(e: &EventDecl) -> String {
1165 let base = describe_type(&e.as_type_decl());
1166 if e.annotations.is_empty() {
1167 return base;
1168 }
1169 let annotations: String = e
1170 .annotations
1171 .iter()
1172 .map(|a| format!(" {}", bynk_fmt::annotation_to_string(a)))
1173 .collect();
1174 let from = format!("type {} = ", e.name.name);
1175 let to = format!("type {}{annotations} = ", e.name.name);
1176 base.replacen(&from, &to, 1)
1177}
1178
1179pub(crate) fn describe_fn(f: &FnDecl) -> String {
1180 let mut out = String::new();
1181 out.push_str("```bynk\n");
1182 out.push_str("fn ");
1183 out.push_str(&f.name.display());
1184 out.push('(');
1185 let mut parts: Vec<String> = Vec::new();
1186 if f.has_self {
1187 parts.push("self".into());
1188 }
1189 for p in &f.params {
1190 parts.push(format!("{}: {}", p.name.name, type_ref_str(&p.type_ref)));
1191 }
1192 out.push_str(&parts.join(", "));
1193 out.push_str(") -> ");
1194 out.push_str(&type_ref_str(&f.return_type));
1195 for c in &f.requires {
1198 out.push_str(&format!(
1199 "\n\trequires {}: {}",
1200 c.name.name,
1201 bynk_fmt::expr_to_string(&c.predicate)
1202 ));
1203 }
1204 for c in &f.ensures {
1205 out.push_str(&format!(
1206 "\n\tensures {}: {}",
1207 c.name.name,
1208 bynk_fmt::expr_to_string(&c.predicate)
1209 ));
1210 }
1211 out.push_str("\n```\n");
1212 if let Some(doc) = &f.documentation {
1213 out.push('\n');
1214 out.push_str(doc);
1215 out.push('\n');
1216 }
1217 out
1218}
1219
1220pub(crate) fn describe_capability(c: &CapabilityDecl) -> String {
1221 let mut out = String::new();
1222 out.push_str("```bynk\ncapability ");
1223 out.push_str(&c.name.name);
1224 out.push_str(" {\n");
1225 for op in &c.ops {
1226 out.push_str("\tfn ");
1227 out.push_str(&op.name.name);
1228 out.push_str(&capability_op_type_params_str(op));
1229 out.push('(');
1230 let parts: Vec<String> = op
1231 .params
1232 .iter()
1233 .map(|p| format!("{}: {}", p.name.name, type_ref_str(&p.type_ref)))
1234 .collect();
1235 out.push_str(&parts.join(", "));
1236 out.push_str(") -> ");
1237 out.push_str(&type_ref_str(&op.return_type));
1238 out.push('\n');
1239 }
1240 out.push_str("}\n```\n");
1241 if let Some(doc) = &c.documentation {
1242 out.push('\n');
1243 out.push_str(doc);
1244 out.push('\n');
1245 }
1246 out
1247}
1248
1249pub(crate) fn describe_messages(m: &MessagesDecl) -> String {
1253 let mut out = String::new();
1254 out.push_str("```bynk\nmessages ");
1255 out.push('"');
1256 out.push_str(&m.tag);
1257 out.push('"');
1258 for ann in &m.annotations {
1259 out.push(' ');
1260 out.push_str(&bynk_fmt::annotation_to_string(ann));
1261 }
1262 out.push_str(" {\n");
1263 for entry in &m.entries {
1264 let icu_kinds: Vec<String> = bynk_check::icu::template_format_kinds(&entry.template)
1269 .into_iter()
1270 .map(|(name, kind)| (name.to_string(), kind.as_str()))
1271 .filter(|(_, kind)| *kind != "plain")
1272 .map(|(name, kind)| format!("{name}: {kind}"))
1273 .collect();
1274 if icu_kinds.is_empty() {
1275 out.push_str(&format!("\t\"{}\" => …\n", entry.code));
1276 } else {
1277 out.push_str(&format!(
1278 "\t\"{}\" => … // {}\n",
1279 entry.code,
1280 icu_kinds.join(", ")
1281 ));
1282 }
1283 }
1284 out.push_str("}\n```\n");
1285 if let Some(doc) = &m.documentation {
1286 out.push('\n');
1287 out.push_str(doc);
1288 out.push('\n');
1289 }
1290 out
1291}
1292
1293fn service_protocol_suffix(p: &ServiceProtocol) -> String {
1296 match p {
1297 ServiceProtocol::Call => String::new(),
1298 ServiceProtocol::Http => " from http".to_string(),
1299 ServiceProtocol::Cron => " from cron".to_string(),
1300 ServiceProtocol::Queue { name } => format!(" from queue(\"{name}\")"),
1301 ServiceProtocol::WebSocket { .. } => " from websocket".to_string(),
1302 ServiceProtocol::Events { event_type, .. } => {
1307 format!(" from Events({})", type_ref_str(event_type))
1308 }
1309 }
1310}
1311
1312fn cors_summary(cors: &CorsPolicy) -> String {
1315 let mut parts = vec![format!("origins: {:?}", cors.origins())];
1316 if cors.credentials() {
1317 parts.push("credentials: true".to_string());
1318 }
1319 if let Some(secs) = cors.max_age_secs() {
1320 parts.push(format!("maxAge: {secs}s"));
1321 }
1322 parts.join(", ")
1323}
1324
1325fn security_summary(security: &SecurityPolicy) -> String {
1328 let mut parts = Vec::new();
1329 if !security.nosniff() {
1330 parts.push("nosniff: false".to_string());
1331 }
1332 if let Some(secs) = security.hsts_max_age_secs() {
1333 parts.push(format!("hsts: {secs}s"));
1334 }
1335 if parts.is_empty() {
1336 "nosniff".to_string()
1338 } else {
1339 parts.join(", ")
1340 }
1341}
1342
1343fn limits_summary(limits: &LimitsPolicy) -> String {
1346 match limits.max_body() {
1347 Some(bytes) => format!("maxBody: {bytes} bytes"),
1348 None => "maxBody".to_string(),
1349 }
1350}
1351
1352pub(crate) fn handler_line(h: &Handler) -> String {
1354 match &h.kind {
1355 HandlerKind::Call => "on call".to_string(),
1356 HandlerKind::Http { method, path } => format!("on {}(\"{}\")", method.as_str(), path),
1357 HandlerKind::Cron { expr } => format!("on schedule(\"{expr}\")"),
1358 HandlerKind::Message => "on message".to_string(),
1359 HandlerKind::Open => "on open".to_string(),
1360 HandlerKind::Close => "on close".to_string(),
1361 HandlerKind::Event => "on event".to_string(),
1362 }
1363}
1364
1365pub(crate) fn describe_service(s: &ServiceDecl) -> String {
1366 let mut out = format!(
1369 "```bynk\nservice {}{} {{\n",
1370 s.name.name,
1371 service_protocol_suffix(&s.protocol)
1372 );
1373 if let Some(cors) = &s.cors {
1376 out.push_str(&format!("\tcors {{ {} }}\n", cors_summary(cors)));
1377 }
1378 if let Some(security) = &s.security {
1380 out.push_str(&format!(
1381 "\tsecurity {{ {} }}\n",
1382 security_summary(security)
1383 ));
1384 }
1385 if let Some(limits) = &s.limits {
1387 out.push_str(&format!("\tlimits {{ {} }}\n", limits_summary(limits)));
1388 }
1389 for h in &s.handlers {
1390 out.push_str(&format!("\t{}\n", handler_line(h)));
1391 }
1392 out.push_str("}\n```\n");
1393 if let Some(doc) = &s.documentation {
1394 out.push('\n');
1395 out.push_str(doc);
1396 out.push('\n');
1397 }
1398 out
1399}
1400
1401fn store_kind_str(k: &StoreKind) -> String {
1404 if k.args.is_empty() {
1405 k.head.name.clone()
1406 } else {
1407 let args: Vec<String> = k.args.iter().map(type_ref_str).collect();
1408 format!("{}[{}]", k.head.name, args.join(", "))
1409 }
1410}
1411
1412pub(crate) fn describe_agent(a: &AgentDecl) -> String {
1413 let mut out = format!(
1416 "```bynk\nagent {} {{\n\tkey {}: {}\n",
1417 a.name.name,
1418 a.key_name.name,
1419 type_ref_str(&a.key_type),
1420 );
1421 for f in &a.store_fields {
1422 out.push_str(&format!(
1423 "\tstore {}: {}\n",
1424 f.name.name,
1425 store_kind_str(&f.kind)
1426 ));
1427 }
1428 for inv in &a.invariants {
1429 out.push_str(&format!(
1430 "\tinvariant {}: {}\n",
1431 inv.name.name,
1432 bynk_fmt::expr_to_string(&inv.predicate)
1433 ));
1434 }
1435 for tr in &a.transitions {
1436 out.push_str(&format!(
1437 "\ttransition {}: {}\n",
1438 tr.name.name,
1439 bynk_fmt::expr_to_string(&tr.predicate)
1440 ));
1441 }
1442 out.push_str("}\n```\n");
1443 if let Some(doc) = &a.documentation {
1444 out.push('\n');
1445 out.push_str(doc);
1446 out.push('\n');
1447 }
1448 out
1449}
1450
1451pub(crate) fn describe_provider(p: &ProviderDecl) -> String {
1452 let mut out = format!(
1453 "```bynk\nprovides {} = {}\n```\n",
1454 p.capability.name, p.provider_name.name
1455 );
1456 if let Some(doc) = &p.documentation {
1457 out.push('\n');
1458 out.push_str(doc);
1459 out.push('\n');
1460 }
1461 out
1462}
1463
1464pub struct CrossFileSymbol {
1469 pub path: PathBuf,
1470 pub span: Span,
1471 pub source: String,
1472}
1473
1474fn sorted_paths(files: &HashMap<PathBuf, String>) -> Vec<&PathBuf> {
1479 let mut paths: Vec<&PathBuf> = files.keys().collect();
1480 paths.sort();
1481 paths
1482}
1483
1484pub fn find_declaration_cross_file(
1495 files: &HashMap<PathBuf, String>,
1496 current_path: &Path,
1497 name: &str,
1498) -> Option<CrossFileSymbol> {
1499 for path in sorted_paths(files) {
1500 if path.as_path() == current_path {
1501 continue;
1502 }
1503 let source = &files[path];
1504 if let Some(span) = find_declaration_span(source, name) {
1505 return Some(CrossFileSymbol {
1506 path: path.clone(),
1507 span,
1508 source: source.clone(),
1509 });
1510 }
1511 }
1512 None
1513}
1514
1515pub fn describe_symbol_cross_file(
1521 files: &HashMap<PathBuf, String>,
1522 current_path: &Path,
1523 name: &str,
1524) -> Option<(PathBuf, String)> {
1525 for path in sorted_paths(files) {
1526 if path.as_path() == current_path {
1527 continue;
1528 }
1529 let source = &files[path];
1530 if let Some(desc) = describe_symbol(source, name) {
1531 return Some((path.clone(), desc));
1532 }
1533 }
1534 None
1535}
1536
1537pub fn type_ref_str(t: &TypeRef) -> String {
1538 match t {
1539 TypeRef::Fn(params, ret, _) => {
1541 let lhs = match params.len() {
1542 0 => "()".to_string(),
1543 1 if !matches!(params[0], TypeRef::Fn(..)) => type_ref_str(¶ms[0]),
1544 _ => format!(
1545 "({})",
1546 params
1547 .iter()
1548 .map(type_ref_str)
1549 .collect::<Vec<_>>()
1550 .join(", ")
1551 ),
1552 };
1553 format!("{lhs} -> {}", type_ref_str(ret))
1554 }
1555 TypeRef::Base(b, _) => b.name().to_string(),
1556 TypeRef::Named(id) => id.name.clone(),
1557 TypeRef::Result(a, b, _) => format!("Result[{}, {}]", type_ref_str(a), type_ref_str(b)),
1558 TypeRef::Option(t, _) => format!("Option[{}]", type_ref_str(t)),
1559 TypeRef::Effect(t, _) => format!("Effect[{}]", type_ref_str(t)),
1560 TypeRef::HttpResult(t, _) => format!("HttpResult[{}]", type_ref_str(t)),
1561 TypeRef::QueueResult(_) => "QueueResult".to_string(),
1562 TypeRef::List(t, _) => format!("List[{}]", type_ref_str(t)),
1564 TypeRef::Query(t, _) => format!("Query[{}]", type_ref_str(t)),
1565 TypeRef::Stream(t, _) => format!("Stream[{}]", type_ref_str(t)),
1566 TypeRef::Connection(t, _) => format!("Connection[{}]", type_ref_str(t)),
1567 TypeRef::History(t, _) => format!("History[{}]", type_ref_str(t)),
1568 TypeRef::Map(k, v, _) => format!("Map[{}, {}]", type_ref_str(k), type_ref_str(v)),
1569 TypeRef::ValidationError(_) => "ValidationError".to_string(),
1570 TypeRef::JsonError(_) => "JsonError".to_string(),
1571 TypeRef::Unit(_) => "()".to_string(),
1572 TypeRef::App { name, args, .. } => format!(
1574 "{}[{}]",
1575 name.name,
1576 args.iter().map(type_ref_str).collect::<Vec<_>>().join(", ")
1577 ),
1578 }
1579}
1580
1581#[cfg(test)]
1582mod tests {
1583 use super::*;
1584
1585 fn project_files(root: &Path, files: &[(&str, &str)]) -> HashMap<PathBuf, String> {
1591 files
1592 .iter()
1593 .map(|(rel, contents)| (root.join(rel), (*contents).to_string()))
1594 .collect()
1595 }
1596
1597 #[test]
1598 fn receiver_segment_survives_a_multibyte_char_before_the_receiver() {
1599 let text = "\"€p.items"; let member_start = text.rfind("items").unwrap();
1604 let (recv, start) =
1605 receiver_segment_at(text, Span::new(member_start, text.len())).expect("receiver");
1606 assert_eq!(recv, "p");
1607 assert_eq!(&text[start..start + recv.len()], "p");
1608 }
1609
1610 #[test]
1611 fn cross_file_definition_resolves_into_sibling_file() {
1612 let root = PathBuf::from("/synthetic/cross_file_definition");
1613 let files = project_files(
1614 &root,
1615 &[
1616 (
1617 "a.bynk",
1618 "commons demo.a\n\ntype Foo = Int where Positive\n",
1619 ),
1620 (
1621 "b.bynk",
1622 "commons demo.b\n\nuses demo.a\n\ntype Bar = Int where NonNegative\n",
1623 ),
1624 ],
1625 );
1626 let current = root.join("b.bynk");
1627 let found = find_declaration_cross_file(&files, ¤t, "Foo")
1628 .expect("Foo should resolve into a.bynk");
1629 let expected = root.join("a.bynk");
1630 assert_eq!(found.path, expected);
1631 assert!(
1632 found.source.contains("type Foo = Int where Positive"),
1633 "source returned does not contain Foo declaration"
1634 );
1635 }
1636
1637 #[test]
1638 fn cross_file_definition_skips_current_file() {
1639 let root = PathBuf::from("/synthetic/cross_file_skip_current");
1640 let files = project_files(
1641 &root,
1642 &[(
1643 "only.bynk",
1644 "commons demo.only\n\ntype Foo = Int where Positive\n",
1645 )],
1646 );
1647 let current = root.join("only.bynk");
1648 assert!(find_declaration_cross_file(&files, ¤t, "Foo").is_none());
1650 }
1651
1652 #[test]
1653 fn cross_file_hover_returns_markdown_summary() {
1654 let root = PathBuf::from("/synthetic/cross_file_hover");
1655 let files = project_files(
1656 &root,
1657 &[
1658 (
1659 "money.bynk",
1660 "commons demo.money\n\n\
1661 ---\n\
1662 Amount in minor units of currency.\n\
1663 ---\n\
1664 type Money = Int where NonNegative\n",
1665 ),
1666 (
1667 "orders.bynk",
1668 "commons demo.orders\n\nuses demo.money\n\ntype OrderId = Int where Positive\n",
1669 ),
1670 ],
1671 );
1672 let current = root.join("orders.bynk");
1673 let (other_path, desc) = describe_symbol_cross_file(&files, ¤t, "Money")
1674 .expect("Money should produce hover content");
1675 assert_eq!(other_path, root.join("money.bynk"));
1676 assert!(desc.contains("type Money"));
1677 assert!(
1678 desc.contains("Amount in minor units"),
1679 "hover should include the doc block"
1680 );
1681 }
1682
1683 #[test]
1684 fn cross_file_returns_none_for_unknown_name() {
1685 let root = PathBuf::from("/synthetic/cross_file_none");
1686 let files = project_files(
1687 &root,
1688 &[(
1689 "a.bynk",
1690 "commons demo.a\n\ntype Foo = Int where Positive\n",
1691 )],
1692 );
1693 let current = root.join("a.bynk");
1694 assert!(find_declaration_cross_file(&files, ¤t, "DoesNotExist").is_none());
1695 assert!(describe_symbol_cross_file(&files, ¤t, "DoesNotExist").is_none());
1696 }
1697
1698 #[test]
1699 fn first_party_symbols_describe_their_signature_and_doc() {
1700 let reverse = describe_firstparty_symbol("reverse").expect("`bynk.list.reverse` described");
1704 assert!(
1705 reverse.contains("reverse") && reverse.contains("List"),
1706 "{reverse}"
1707 );
1708 assert!(
1709 reverse.contains("reverse order"),
1710 "doc block surfaced: {reverse}"
1711 );
1712 let clock = describe_firstparty_symbol("Clock").expect("`bynk`-surface `Clock`");
1714 assert!(
1715 clock.contains("wall-clock"),
1716 "capability doc surfaced: {clock}"
1717 );
1718 let locale_tag = describe_firstparty_symbol("LocaleTag")
1722 .expect("`bynk.locale.types.LocaleTag` described");
1723 assert!(
1724 locale_tag.contains("LocaleTag") && locale_tag.contains("Matches"),
1725 "{locale_tag}"
1726 );
1727 let render = describe_firstparty_symbol("render").expect("`bynk.locale.render` described");
1728 assert!(
1729 render.contains("render") && render.contains("LocaleTag"),
1730 "{render}"
1731 );
1732 let with_text =
1733 describe_firstparty_symbol("withText").expect("`bynk.locale.withText` described");
1734 assert!(with_text.contains("withText"), "{with_text}");
1735 assert!(describe_firstparty_symbol("DoesNotExist").is_none());
1737 }
1738
1739 #[test]
1740 fn unit_reference_spans_finds_uses_and_consumes_targets() {
1741 let src = "context app.main\n uses billing.charge\n consumes platform.time\n";
1744 let spans = unit_reference_spans(src);
1745 let names: Vec<&str> = spans.iter().map(|(n, _)| n.as_str()).collect();
1746 assert!(names.contains(&"billing.charge"), "{names:?}");
1747 assert!(names.contains(&"platform.time"), "{names:?}");
1748 let (_, span) = spans.iter().find(|(n, _)| n == "billing.charge").unwrap();
1750 assert_eq!(&src[span.start..span.end], "billing.charge");
1751 }
1752
1753 #[test]
1754 fn unit_reference_spans_links_the_suite_target() {
1755 let src = "suite todos\n uses billing.charge\n";
1758 let spans = unit_reference_spans(src);
1759 let names: Vec<&str> = spans.iter().map(|(n, _)| n.as_str()).collect();
1760 assert!(names.contains(&"todos"), "{names:?}");
1761 assert!(names.contains(&"billing.charge"), "{names:?}");
1762 let (_, span) = spans.iter().find(|(n, _)| n == "todos").unwrap();
1764 assert_eq!(&src[span.start..span.end], "todos");
1765 }
1766
1767 #[test]
1768 fn scan_doc_link_candidates_finds_a_bare_shortcut() {
1769 let cands = scan_doc_link_candidates("See [Limiter] for details.");
1770 assert_eq!(cands.len(), 1);
1771 assert_eq!(cands[0].name, "Limiter");
1772 assert_eq!(cands[0].display, "Limiter");
1773 assert_eq!(
1774 &"See [Limiter] for details."[cands[0].span.clone()],
1775 "[Limiter]"
1776 );
1777 }
1778
1779 #[test]
1780 fn scan_doc_link_candidates_finds_a_dotted_shortcut() {
1781 let cands = scan_doc_link_candidates("See [RateView.remaining].");
1782 assert_eq!(cands.len(), 1);
1783 assert_eq!(cands[0].name, "RateView.remaining");
1784 }
1785
1786 #[test]
1787 fn scan_doc_link_candidates_finds_a_code_span_shortcut() {
1788 let text = "The [`RateView.remaining`] field is never negative.";
1789 let cands = scan_doc_link_candidates(text);
1790 assert_eq!(cands.len(), 1);
1791 assert_eq!(cands[0].name, "RateView.remaining");
1792 assert_eq!(cands[0].display, "`RateView.remaining`");
1793 assert_eq!(&text[cands[0].span.clone()], "[`RateView.remaining`]");
1794 }
1795
1796 #[test]
1797 fn scan_doc_link_candidates_finds_a_full_reference() {
1798 let cands = scan_doc_link_candidates("See [the limiter][Limiter] for details.");
1799 assert_eq!(cands.len(), 1);
1800 assert_eq!(cands[0].name, "Limiter");
1801 assert_eq!(cands[0].display, "the limiter");
1802 }
1803
1804 #[test]
1805 fn scan_doc_link_candidates_leaves_an_explicit_url_untouched() {
1806 let cands = scan_doc_link_candidates("See [Limiter](https://example.com).");
1807 assert!(cands.is_empty(), "{cands:?}");
1808 }
1809
1810 #[test]
1811 fn scan_doc_link_candidates_leaves_an_author_defined_reference_untouched() {
1812 let text = "See [foo] for details.\n\n[foo]: https://example.com\n";
1813 let cands = scan_doc_link_candidates(text);
1814 assert!(cands.is_empty(), "{cands:?}");
1815 }
1816
1817 #[test]
1818 fn scan_doc_link_candidates_ignores_non_identifier_bracket_content() {
1819 let cands = scan_doc_link_candidates("See [see note] and [1] below.");
1820 assert!(cands.is_empty(), "{cands:?}");
1821 }
1822
1823 #[test]
1824 fn scan_doc_link_candidates_ignores_fenced_code_block_content() {
1825 let text = "Example:\n```\n[Bracket, Syntax]\n[label]: not-a-real-def\n```\nSee [Limiter].";
1826 let cands = scan_doc_link_candidates(text);
1827 assert_eq!(cands.len(), 1);
1828 assert_eq!(cands[0].name, "Limiter");
1829 }
1830
1831 #[test]
1832 fn scan_doc_link_candidates_does_not_recognise_the_collapsed_reference_form() {
1833 let cands = scan_doc_link_candidates("See [Limiter][].");
1836 assert!(cands.is_empty(), "{cands:?}");
1837 }
1838
1839 #[test]
1840 fn doc_link_spans_reports_absolute_offsets_in_an_indented_doc_block() {
1841 let src = "context app.main\n ---\n See [Limiter] here.\n ---\n service api {}\n";
1842 let spans = doc_link_spans(src);
1843 assert_eq!(spans.len(), 1);
1844 let (name, span) = &spans[0];
1845 assert_eq!(name, "Limiter");
1846 assert_eq!(&src[span.start..span.end], "[Limiter]");
1847 }
1848
1849 #[test]
1850 fn doc_link_spans_finds_links_across_multiple_doc_blocks() {
1851 let src = "---\nSee [Foo].\n---\ntype Foo = Int\n\n---\nSee [Bar].\n---\ntype Bar = Int\n";
1852 let spans = doc_link_spans(src);
1853 let names: Vec<&str> = spans.iter().map(|(n, _)| n.as_str()).collect();
1854 assert_eq!(names, vec!["Foo", "Bar"]);
1855 }
1856
1857 #[test]
1858 fn own_declaration_name_finds_context_and_commons_names() {
1859 let src = "context app.main\n uses billing.charge\n";
1861 let (name, span) = own_declaration_name(src).expect("context has its own name");
1862 assert_eq!(name, "app.main");
1863 assert_eq!(&src[span.start..span.end], "app.main");
1864
1865 let src = "commons app.util\n\ntype Foo = Int where Positive\n";
1866 let (name, _) = own_declaration_name(src).expect("commons has its own name");
1867 assert_eq!(name, "app.util");
1868 }
1869
1870 #[test]
1871 fn own_declaration_name_none_for_suites() {
1872 let src = "suite todos\n";
1876 assert!(own_declaration_name(src).is_none());
1877 }
1878
1879 #[test]
1882 fn describe_type_renders_fields_variants_and_refinements() {
1883 let record = describe_symbol(
1884 "commons demo.m\n\ntype Order = {\n id: OrderId,\n total: Money,\n}\n",
1885 "Order",
1886 )
1887 .unwrap();
1888 assert!(record.contains("type Order = {"), "{record}");
1889 assert!(record.contains("id: OrderId"), "{record}");
1890 assert!(record.contains("total: Money"), "{record}");
1891
1892 let sum = describe_symbol(
1893 "commons demo.m\n\ntype Status = enum { Pending, Shipped }\n",
1894 "Status",
1895 )
1896 .unwrap();
1897 assert!(sum.contains("enum {"), "{sum}");
1898 assert!(sum.contains("Pending") && sum.contains("Shipped"), "{sum}");
1899
1900 let refined = describe_symbol(
1901 "commons demo.m\n\ntype Email = String where NonEmpty\n",
1902 "Email",
1903 )
1904 .unwrap();
1905 assert!(
1906 refined.contains("type Email = String where NonEmpty"),
1907 "{refined}"
1908 );
1909
1910 let opaque =
1911 describe_symbol("commons demo.m\n\ntype Token = opaque String\n", "Token").unwrap();
1912 assert!(opaque.contains("type Token = opaque String"), "{opaque}");
1913 }
1914
1915 #[test]
1918 fn describe_fn_renders_contracts() {
1919 let src = "commons demo.m\n\nfn discount(p: Int, pct: Int) -> Int\n requires p_nonneg: p >= 0\n ensures never_negative: result >= 0\n{\n p\n}\n";
1920 let out = describe_symbol(src, "discount").unwrap();
1921 assert!(
1922 out.contains("fn discount(p: Int, pct: Int) -> Int"),
1923 "{out}"
1924 );
1925 assert!(out.contains("requires p_nonneg: p >= 0"), "{out}");
1926 assert!(out.contains("ensures never_negative: result >= 0"), "{out}");
1927 }
1928
1929 #[test]
1931 fn describe_service_renders_routes() {
1932 let src = "context demo.app\n\nservice greeter {\n on call(name: String) -> Effect[String] {\n Effect.pure(name)\n }\n}\n";
1933 let out = describe_symbol(src, "greeter").unwrap();
1934 assert!(out.contains("service greeter {"), "{out}");
1935 assert!(out.contains("on call"), "{out}");
1936 }
1937
1938 #[test]
1941 fn describe_agent_renders_store_and_invariants() {
1942 let src = "context demo.app\n\nagent Counter {\n key id: String\n store count: Cell[Int] = 0\n invariant non_negative: count >= 0\n transition monotonic: new.count >= old.count\n on call bump() -> Effect[Result[(), String]] {\n Ok(())\n }\n}\n";
1943 let out = describe_symbol(src, "Counter").unwrap();
1944 assert!(out.contains("agent Counter {"), "{out}");
1945 assert!(out.contains("key id: String"), "{out}");
1946 assert!(out.contains("store count: Cell[Int]"), "{out}");
1947 assert!(out.contains("invariant non_negative: count >= 0"), "{out}");
1948 assert!(
1949 out.contains("transition monotonic: new.count >= old.count"),
1950 "{out}"
1951 );
1952 }
1953
1954 #[test]
1957 fn qualified_callee_detects_upper_receiver_only() {
1958 let text = " let t = Clock.now()";
1959 let now = text.find("now").unwrap();
1960 assert_eq!(
1961 qualified_callee_at(text, Span::new(now, now + 3)).as_deref(),
1962 Some("Clock.now")
1963 );
1964 let text2 = " xs.fold(0)";
1967 let fold = text2.find("fold").unwrap();
1968 assert!(qualified_callee_at(text2, Span::new(fold, fold + 4)).is_none());
1969 let text3 = " total";
1971 assert!(qualified_callee_at(text3, Span::new(2, 7)).is_none());
1972 }
1973
1974 #[test]
1978 fn describe_agent_state_covers_key_store_keywords_and_fields() {
1979 let src = "context demo.app\n\nagent Sessions {\n key id: String\n store items: Map[String, Int] @indexed( by: id ) @bounded( 10000 )\n on call read() -> Effect[Int] {\n Effect.pure(0)\n }\n}\n";
1980
1981 let at_key_kw = src.find("key id").unwrap();
1984 let key_kw = describe_agent_state_at(src, at_key_kw).expect("hover on `key`");
1985 assert!(key_kw.contains("key id: String"), "{key_kw}");
1986 assert!(key_kw.contains("identity field"), "doc line: {key_kw}");
1987 let at_key_name = src.find("id: String").unwrap();
1988 let key_name = describe_agent_state_at(src, at_key_name).expect("hover on the key field");
1989 assert_eq!(key_kw, key_name, "keyword and name hover match");
1990
1991 let at_store_kw = src.find("store items").unwrap();
1994 let store_kw = describe_agent_state_at(src, at_store_kw).expect("hover on `store`");
1995 assert!(
1996 store_kw.contains("store items: Map[String, Int]"),
1997 "{store_kw}"
1998 );
1999 assert!(
2000 store_kw.contains("@indexed(by: id)"),
2001 "annotation: {store_kw}"
2002 );
2003 assert!(
2004 store_kw.contains("@bounded(10000)"),
2005 "annotation: {store_kw}"
2006 );
2007 assert!(
2008 store_kw.contains("persisted agent-state"),
2009 "doc line: {store_kw}"
2010 );
2011 let at_store_name = src.find("items:").unwrap();
2012 let store_name =
2013 describe_agent_state_at(src, at_store_name).expect("hover on the store field");
2014 assert_eq!(store_kw, store_name, "keyword and name hover match");
2015
2016 assert!(describe_agent_state_at(src, src.find("Sessions").unwrap()).is_none());
2019 assert!(describe_agent_state_at(src, src.find("Map").unwrap()).is_none());
2020 assert!(describe_agent_state_at(src, src.find("by: id").unwrap() + 4).is_none());
2023 }
2024
2025 const TODOS: &str = "context demo.todos\n\
2030 \n\
2031 agent Todos {\n\
2032 \x20 key owner: String\n\
2033 \n\
2034 \x20 store items: Map[String, Int]\n\
2035 \x20 store lastSeq: Cell[Int]\n\
2036 \n\
2037 \x20 invariant nonneg: lastSeq >= 0\n\
2038 \n\
2039 \x20 on call add(n: Int) -> Effect[()] {\n\
2040 \x20 let next = lastSeq + 1\n\
2041 \x20 let _ <- items.put(owner, next)\n\
2042 \x20 lastSeq := next\n\
2043 \x20 Effect.pure(())\n\
2044 \x20 }\n\
2045 }\n";
2046
2047 #[test]
2048 fn describe_agent_state_covers_references_in_handler_bodies() {
2049 let src = TODOS;
2050 let at = |needle: &str| src.find(needle).expect("needle is in the fixture");
2051
2052 let store_decl = describe_agent_state_at(src, at("store lastSeq")).unwrap();
2054 assert!(
2055 store_decl.contains("store lastSeq: Cell[Int]"),
2056 "{store_decl}"
2057 );
2058 for (what, needle) in [
2059 ("a bare read", "lastSeq + 1"),
2060 ("a `:=` write target", "lastSeq := next"),
2061 ("an invariant subject", "lastSeq >= 0"),
2062 ] {
2063 let hover = describe_agent_state_at(src, at(needle))
2064 .unwrap_or_else(|| panic!("no hover on {what}"));
2065 assert_eq!(hover, store_decl, "{what} hovers as its declaration");
2066 }
2067 let recv = describe_agent_state_at(src, at("items.put")).expect("hover on the receiver");
2069 assert_eq!(
2070 recv,
2071 describe_agent_state_at(src, at("store items")).unwrap()
2072 );
2073
2074 let key_decl = describe_agent_state_at(src, at("key owner")).unwrap();
2076 let key_ref = describe_agent_state_at(src, at("owner, next")).expect("hover on `owner`");
2077 assert_eq!(key_ref, key_decl);
2078 assert!(key_ref.contains("key owner: String"), "{key_ref}");
2079
2080 assert!(describe_agent_state_at(src, at("next = lastSeq")).is_none());
2083 assert!(describe_agent_state_at(src, at("Effect.pure")).is_none());
2084 }
2085
2086 #[test]
2089 fn describe_store_op_renders_the_operation_signature() {
2090 let src = TODOS;
2091 let at_put = src
2092 .find("put(owner")
2093 .expect("the store op is in the fixture");
2094 let put = describe_store_op_at(src, at_put, &[]).expect("hover on `items.put`");
2095 assert!(put.contains("put(key: K, value: V) -> Effect[()]"), "{put}");
2096 assert!(put.contains("store items: Map[String, Int]"), "{put}");
2098
2099 let shadow = [bynk_check::locals::LocalBinding {
2102 name: "items".into(),
2103 def_span: Span::new(0, 5),
2104 kind: bynk_check::locals::LocalKind::Let,
2105 ty: "Map[String, Int]".into(),
2106 scope: Span::new(0, src.len()),
2107 }];
2108 assert!(describe_store_op_at(src, at_put, &shadow).is_none());
2109
2110 let cell_put = src.replace("items.put(owner, next)", "lastSeq.put(owner, next)");
2113 assert!(
2114 describe_store_op_at(&cell_put, cell_put.find("put(owner").unwrap(), &[]).is_none(),
2115 "a `Cell` has no `put`"
2116 );
2117 assert!(describe_store_op_at(src, src.find("pure(()").unwrap(), &[]).is_none());
2118 assert!(describe_store_op_at(src, src.find("next = lastSeq").unwrap(), &[]).is_none());
2119 }
2120
2121 #[test]
2127 fn a_qualified_receiver_is_not_a_store_op_or_a_state_reference() {
2128 let src = "context demo.todos\n\
2129 \n\
2130 type Inner = { put: Int }\n\
2131 type Payload = { items: Inner, lastSeq: Int }\n\
2132 \n\
2133 agent Todos {\n\
2134 \x20 key owner: String\n\
2135 \x20 store items: Map[String, Int]\n\
2136 \x20 store lastSeq: Cell[Int]\n\
2137 \n\
2138 \x20 on call add(p: Payload) -> Effect[()] {\n\
2139 \x20 let a = p.items.put\n\
2140 \x20 let b = p.lastSeq\n\
2141 \x20 Effect.pure(())\n\
2142 \x20 }\n\
2143 }\n";
2144 let at_put = src.find("put\n").expect("the qualified member");
2146 assert!(describe_store_op_at(src, at_put, &[]).is_none());
2147 let at_items = src.find("p.items").unwrap() + "p.".len();
2150 assert!(describe_agent_state_at(src, at_items).is_none());
2151 let at_seq = src.find("p.lastSeq").unwrap() + "p.".len();
2152 assert!(describe_agent_state_at(src, at_seq).is_none());
2153
2154 let bare = src.find("store items").unwrap();
2156 assert!(describe_agent_state_at(src, bare).is_some());
2157 }
2158
2159 #[test]
2164 fn describe_symbol_renders_a_resolved_record_field() {
2165 let src = "context demo.todos\n\n\
2166 type Title = String where NonEmpty\n\n\
2167 type Stored = {\n\
2168 \x20 seq: Int where NonNegative,\n\
2169 \x20 title: Title,\n\
2170 }\n";
2171 let title = describe_symbol(src, "Stored.title").expect("hover on `Stored.title`");
2172 assert!(title.contains("title: Title"), "{title}");
2173 assert!(title.contains("A field of `Stored`"), "{title}");
2174 let seq = describe_symbol(src, "Stored.seq").expect("hover on `Stored.seq`");
2176 assert!(seq.contains("seq: Int where NonNegative"), "{seq}");
2177
2178 assert!(
2180 describe_symbol(src, "Stored")
2181 .unwrap()
2182 .contains("type Stored")
2183 );
2184 assert!(describe_symbol(src, "Stored.nope").is_none());
2186 assert!(describe_symbol(src, "Nope.title").is_none());
2187 assert!(describe_symbol(src, "Title.title").is_none());
2188 }
2189
2190 #[test]
2195 fn describe_symbol_renders_an_event_and_its_defaulted_field() {
2196 let src = "context demo.orders\n\n\
2197 type Region = enum { Domestic, International }\n\n\
2198 event PaymentConfirmed = {\n\
2199 \x20 orderId: String,\n\
2200 \x20 region: Region = Region.Domestic,\n\
2201 }\n";
2202 let whole = describe_symbol(src, "PaymentConfirmed").expect("hover on the event itself");
2203 assert!(whole.contains("type PaymentConfirmed = {"), "{whole}");
2204 assert!(whole.contains("orderId: String"), "{whole}");
2205 assert!(
2206 whole.contains("region: Region = Region.Domestic"),
2207 "{whole}"
2208 );
2209
2210 let field = describe_symbol(src, "PaymentConfirmed.region")
2211 .expect("hover on `PaymentConfirmed.region`");
2212 assert!(
2213 field.contains("region: Region = Region.Domestic"),
2214 "{field}"
2215 );
2216 assert!(field.contains("A field of `PaymentConfirmed`"), "{field}");
2217
2218 let order_id = describe_symbol(src, "PaymentConfirmed.orderId")
2220 .expect("hover on `PaymentConfirmed.orderId`");
2221 assert!(order_id.contains("orderId: String"), "{order_id}");
2222 assert!(!order_id.contains('='), "{order_id}");
2223
2224 assert!(describe_symbol(src, "PaymentConfirmed.nope").is_none());
2225 assert!(describe_symbol(src, "Nope.region").is_none());
2226 }
2227
2228 #[test]
2232 fn describe_symbol_renders_an_events_schema_annotation() {
2233 let src = "context demo.orders\n\n\
2234 event PaymentConfirmed @schema(2) = {\n\
2235 \x20 orderId: String,\n\
2236 }\n";
2237 let whole = describe_symbol(src, "PaymentConfirmed").expect("hover on the event itself");
2238 assert!(
2239 whole.contains("type PaymentConfirmed @schema(2) = {"),
2240 "{whole}"
2241 );
2242 assert!(whole.contains("orderId: String"), "{whole}");
2243 }
2244
2245 #[test]
2250 fn describe_symbol_renders_an_actor_in_both_forms() {
2251 let src = "context demo.auth\n\n\
2252 type UserId = String where NonEmpty\n\n\
2253 ---\n\
2254 A signed-in user.\n\
2255 ---\n\
2256 actor User { auth = Bearer(secret = \"AUTH_JWT_SECRET\"), identity = UserId }\n\n\
2257 actor Public { auth = None }\n\n\
2258 actor Worker { auth = Internal }\n\n\
2259 actor Admin = User where hasClaim(\"admin\")\n";
2260
2261 let user = describe_symbol(src, "User").expect("hover on `User`");
2262 assert!(
2263 user.contains(
2264 "actor User { auth = Bearer(secret = \"AUTH_JWT_SECRET\"), identity = UserId }"
2265 ),
2266 "{user}"
2267 );
2268 assert!(user.contains("A signed-in user."), "{user}");
2270
2271 let public = describe_symbol(src, "Public").expect("hover on `Public`");
2273 assert!(
2274 public.contains("actor Public { auth = None }") && !public.contains("identity"),
2275 "{public}"
2276 );
2277 assert!(
2278 describe_symbol(src, "Worker")
2279 .unwrap()
2280 .contains("actor Worker { auth = Internal }")
2281 );
2282
2283 let admin = describe_symbol(src, "Admin").expect("hover on `Admin`");
2285 assert!(
2286 admin.contains("actor Admin = User where hasClaim(\"admin\")"),
2287 "{admin}"
2288 );
2289
2290 assert!(describe_symbol(src, "Nobody").is_none());
2291 }
2292
2293 #[test]
2300 fn describe_symbol_escapes_an_actors_scheme_config() {
2301 let src = "context demo.auth\n\n\
2302 actor User { auth = Bearer(secret = \"a\\\"b\\\\c\") }\n";
2303 let hover = describe_symbol(src, "User").expect("hover on `User`");
2304 assert!(
2305 hover.contains("actor User { auth = Bearer(secret = \"a\\\"b\\\\c\") }"),
2306 "the config value must round-trip escaped:\n{hover}"
2307 );
2308
2309 let formatted = bynk_fmt::format_source(src, &bynk_fmt::FormatOptions::default())
2311 .expect("the fixture formats");
2312 let actor_line = formatted
2313 .lines()
2314 .find(|l| l.starts_with("actor User"))
2315 .expect("the actor line");
2316 assert!(
2317 hover.contains(actor_line),
2318 "hover:\n{hover}\nfmt: {actor_line}"
2319 );
2320 }
2321
2322 #[test]
2325 fn describe_symbol_renders_a_capability_operation() {
2326 let src = "context demo.svc\n\n\
2327 capability Logger {\n\
2328 \x20 ---\n\
2329 \x20 Record a line.\n\
2330 \x20 ---\n\
2331 \x20 fn info(message: String) -> Effect[()]\n\
2332 }\n\n\
2333 capability Clock {\n\
2334 \x20 fn now() -> Effect[Int]\n\
2335 }\n";
2336
2337 let info = describe_symbol(src, "Logger.info").expect("hover on `Logger.info`");
2338 assert!(
2339 info.contains("fn info(message: String) -> Effect[()]"),
2340 "{info}"
2341 );
2342 assert!(
2343 info.contains("An operation of capability `Logger`"),
2344 "{info}"
2345 );
2346 assert!(info.contains("Record a line."), "{info}");
2347
2348 let now = describe_symbol(src, "Clock.now").expect("hover on `Clock.now`");
2350 assert!(now.contains("fn now() -> Effect[Int]"), "{now}");
2351
2352 assert!(
2354 describe_symbol(src, "Logger")
2355 .unwrap()
2356 .contains("capability Logger")
2357 );
2358 assert!(describe_symbol(src, "Logger.nope").is_none());
2360 assert!(describe_symbol(src, "Clock.info").is_none());
2361 }
2362
2363 #[test]
2364 fn describe_symbol_renders_a_messages_bundle() {
2365 let src = "commons app.bundle\n\n\
2369 ---\n\
2370 The English reference bundle.\n\
2371 ---\n\
2372 messages \"en\" @reference {\n\
2373 \x20 \"greeting\" => \"Hello, {name}!\"\n\
2374 \x20 \"farewell\" => \"Bye\"\n\
2375 }\n";
2376
2377 let info = describe_symbol(src, "en").expect("hover on the `en` messages tag");
2378 assert!(info.contains("messages \"en\" @reference {"), "{info}");
2379 assert!(info.contains("\"greeting\" => …"), "{info}");
2380 assert!(info.contains("\"farewell\" => …"), "{info}");
2381 assert!(info.contains("The English reference bundle."), "{info}");
2382 }
2383
2384 #[test]
2389 fn describe_symbol_keys_methods_by_their_compound_name_only() {
2390 let src = "context demo.shop\n\n\
2391 type Counter = { count: Int }\n\
2392 type Gauge = { level: Int }\n\n\
2393 fn Counter.bump(self) -> Counter { Counter { count: self.count + 1 } }\n\n\
2394 fn Gauge.bump(self) -> Gauge { Gauge { level: self.level + 1 } }\n\n\
2395 fn free(n: Int) -> Int { n }\n";
2396
2397 let counter = describe_symbol(src, "Counter.bump").expect("hover on `Counter.bump`");
2399 assert!(
2400 counter.contains("fn Counter.bump(self) -> Counter") && !counter.contains("Gauge"),
2401 "{counter}"
2402 );
2403 let gauge = describe_symbol(src, "Gauge.bump").expect("hover on `Gauge.bump`");
2404 assert!(
2405 gauge.contains("fn Gauge.bump(self) -> Gauge") && !gauge.contains("Counter"),
2406 "{gauge}"
2407 );
2408
2409 assert!(describe_symbol(src, "bump").is_none());
2412 assert!(
2414 describe_symbol(src, "free")
2415 .unwrap()
2416 .contains("fn free(n: Int) -> Int")
2417 );
2418 assert!(describe_symbol(src, "Counter.nope").is_none());
2419 }
2420
2421 #[test]
2424 fn describe_self_renders_receiver_and_unwraps_agent() {
2425 use bynk_check::checker::{NamedKind, Ty, Types};
2426 let text = "self";
2427 let tys = &Types::new();
2428 let span = Span::new(0, 4);
2429 let account = vec![(
2431 span,
2432 tys.intern(Ty::Named {
2433 name: "Account".into(),
2434 kind: NamedKind::Record,
2435 args: Vec::new(),
2436 }),
2437 )];
2438 assert_eq!(
2439 describe_self_at(text, 0, &account, tys).as_deref(),
2440 Some("```bynk\nself: Account\n```")
2441 );
2442 let agent = vec![(
2445 span,
2446 tys.intern(Ty::Named {
2447 name: "__CounterSelf".into(),
2448 kind: NamedKind::Record,
2449 args: Vec::new(),
2450 }),
2451 )];
2452 assert_eq!(
2453 describe_self_at(text, 0, &agent, tys).as_deref(),
2454 Some("```bynk\nself: Counter\n```")
2455 );
2456 let other = "total";
2459 assert!(
2460 describe_self_at(
2461 other,
2462 0,
2463 &[(
2464 Span::new(0, 5),
2465 tys.intern(Ty::Named {
2466 name: "Int".into(),
2467 kind: NamedKind::Record,
2468 args: Vec::new(),
2469 }),
2470 )],
2471 tys
2472 )
2473 .is_none()
2474 );
2475 }
2476
2477 const CACHE_SVC: &str = "context api\nservice api from http {\n @cache(maxAge: 5.minutes, scope: public)\n on GET(\"/x\") () -> Effect[HttpResult[String]] by v: Visitor {\n Ok(\"y\")\n }\n}\n";
2478
2479 #[test]
2480 fn hover_on_cache_annotation_describes_it() {
2481 let offset = CACHE_SVC.find("cache").unwrap() + 1;
2483 let hover = describe_handler_annotation_at(CACHE_SVC, offset).expect("hovers @cache");
2484 assert!(hover.contains("`@cache`"), "names the annotation: {hover}");
2485 assert!(hover.contains("maxAge"), "documents maxAge: {hover}");
2486 assert!(hover.contains("scope"), "documents scope: {hover}");
2487 let ok_offset = CACHE_SVC.find("Ok(").unwrap() + 1;
2489 assert!(describe_handler_annotation_at(CACHE_SVC, ok_offset).is_none());
2490 }
2491
2492 #[test]
2493 fn annotation_token_spans_cover_name_and_labels() {
2494 let spans = handler_annotation_token_spans(CACHE_SVC);
2495 assert_eq!(spans.len(), 3, "{spans:?}");
2497 let texts: Vec<&str> = spans.iter().map(|s| &CACHE_SVC[s.start..s.end]).collect();
2498 assert_eq!(texts, ["@cache", "maxAge", "scope"]);
2499 let plain = "context api\nservice api from http {\n on GET(\"/x\") () -> Effect[HttpResult[String]] by v: Visitor { Ok(\"y\") }\n}\n";
2501 assert!(handler_annotation_token_spans(plain).is_empty());
2502 }
2503}