use std::path::{Path, PathBuf};
use bynk_syntax::ast::*;
use bynk_syntax::lexer::tokenize;
use bynk_syntax::parser::parse_unit_with_recovery;
use bynk_syntax::span::Span;
use tower_lsp::lsp_types::Url;
pub fn find_declaration_span(source: &str, name: &str) -> Option<Span> {
let tokens = tokenize(source).ok()?;
let (unit, _errs) = parse_unit_with_recovery(&tokens, source);
let unit = unit?;
let items: &[CommonsItem] = match &unit {
SourceUnit::Commons(c) => &c.items,
SourceUnit::Context(c) => &c.items,
SourceUnit::Adapter(a) => &a.items,
SourceUnit::Suite(_) => &[],
};
for item in items {
match item {
CommonsItem::Type(t) if t.name.name == name => return Some(t.name.span),
CommonsItem::Fn(f) if f.name.ident().name == name => return Some(f.name.ident().span),
CommonsItem::Capability(c) if c.name.name == name => return Some(c.name.span),
CommonsItem::Service(s) if s.name.name == name => return Some(s.name.span),
CommonsItem::Agent(a) if a.name.name == name => return Some(a.name.span),
CommonsItem::Provider(p) if p.provider_name.name == name => {
return Some(p.provider_name.span);
}
_ => {}
}
}
None
}
pub fn describe_symbol(source: &str, name: &str) -> Option<String> {
let tokens = tokenize(source).ok()?;
let (unit, _errs) = parse_unit_with_recovery(&tokens, source);
let unit = unit?;
let items: &[CommonsItem] = match &unit {
SourceUnit::Commons(c) => &c.items,
SourceUnit::Context(c) => &c.items,
SourceUnit::Adapter(a) => &a.items,
SourceUnit::Suite(_) => &[],
};
for item in items {
if let Some(summary) = describe_item(item, name) {
return Some(summary);
}
}
None
}
pub fn describe_keyword_at(source: &str, offset: usize) -> Option<&'static str> {
let tokens = tokenize(source).ok()?;
let word = tokens
.iter()
.find(|t| t.span.start <= offset && offset < t.span.end)
.map(|t| &source[t.span.start..t.span.end])?;
bynk_syntax::keywords::KEYWORDS
.iter()
.find(|k| k.word == word)
.map(|k| k.meaning)
}
pub fn describe_agent_state_at(source: &str, offset: usize) -> Option<String> {
let tokens = tokenize(source).ok()?;
let (unit, _errs) = parse_unit_with_recovery(&tokens, source);
let unit = unit?;
let items: &[CommonsItem] = match &unit {
SourceUnit::Commons(c) => &c.items,
SourceUnit::Context(c) => &c.items,
SourceUnit::Adapter(a) => &a.items,
SourceUnit::Suite(_) => &[],
};
for item in items {
let CommonsItem::Agent(a) = item else {
continue;
};
let on_key_kw = preceding_ident_span(&tokens, source, a.key_name.span, "key")
.is_some_and(|s| span_covers(s, offset));
if on_key_kw || span_covers(a.key_name.span, offset) {
let sig = format!("key {}: {}", a.key_name.name, type_ref_str(&a.key_type));
return Some(render_state_hover(&sig, "key"));
}
for f in &a.store_fields {
let store_kw = Span {
start: f.span.start,
end: f.span.start + "store".len(),
};
let on_store_kw = source.get(store_kw.start..store_kw.end) == Some("store")
&& span_covers(store_kw, offset);
if on_store_kw || span_covers(f.name.span, offset) {
let mut sig = format!("store {}: {}", f.name.name, store_kind_str(&f.kind));
for ann in &f.annotations {
sig.push(' ');
sig.push_str(&bynk_fmt::annotation_to_string(ann));
}
return Some(render_state_hover(&sig, "store"));
}
}
}
None
}
pub fn describe_handler_annotation_at(source: &str, offset: usize) -> Option<String> {
let tokens = tokenize(source).ok()?;
let (unit, _errs) = parse_unit_with_recovery(&tokens, source);
let unit = unit?;
let items: &[CommonsItem] = match &unit {
SourceUnit::Commons(c) => &c.items,
SourceUnit::Context(c) => &c.items,
SourceUnit::Adapter(a) => &a.items,
SourceUnit::Suite(_) => &[],
};
for item in items {
let handlers: &[Handler] = match item {
CommonsItem::Service(s) => &s.handlers,
CommonsItem::Agent(a) => &a.handlers,
_ => continue,
};
for h in handlers {
for ann in &h.annotations {
if span_covers(ann.span, offset) {
return Some(render_handler_annotation_hover(ann));
}
}
}
}
None
}
pub fn handler_annotation_token_spans(source: &str) -> Vec<Span> {
let Ok(tokens) = tokenize(source) else {
return Vec::new();
};
let (unit, _errs) = parse_unit_with_recovery(&tokens, source);
let Some(unit) = unit else {
return Vec::new();
};
let items: &[CommonsItem] = match &unit {
SourceUnit::Commons(c) => &c.items,
SourceUnit::Context(c) => &c.items,
SourceUnit::Adapter(a) => &a.items,
SourceUnit::Suite(_) => &[],
};
let mut spans = Vec::new();
for item in items {
let handlers: &[Handler] = match item {
CommonsItem::Service(s) => &s.handlers,
CommonsItem::Agent(a) => &a.handlers,
_ => continue,
};
for h in handlers {
for ann in &h.annotations {
spans.push(Span {
start: ann.span.start,
end: ann.name.span.end,
});
for arg in &ann.args {
if let Some(label) = &arg.label {
spans.push(label.span);
}
}
}
}
}
spans
}
fn render_handler_annotation_hover(ann: &Annotation) -> String {
let sig = bynk_fmt::annotation_to_string(ann);
if ann.name.name == "cache" {
return format!(
"```bynk\n{sig}\n```\n\n\
**`@cache`** — cache this `GET` read. Every eligible `GET` already carries a \
synthesised weak `ETag` and is answered `304 Not Modified` on a matching \
`If-None-Match`; `@cache` adds a `Cache-Control` freshness window on top.\n\n\
- **`maxAge`** — the freshness window, a `Duration` (e.g. `5.minutes`), lowered to \
`Cache-Control: max-age`.\n\
- **`scope`** — `public` or `private` (default `private`; a shared cache stores the \
response only when `public`)."
);
}
format!("```bynk\n{sig}\n```")
}
fn span_covers(span: Span, offset: usize) -> bool {
span.start <= offset && offset < span.end
}
fn preceding_ident_span(
tokens: &[bynk_syntax::lexer::Token],
source: &str,
name_span: Span,
kw: &str,
) -> Option<Span> {
let idx = tokens
.iter()
.position(|t| t.span.start == name_span.start)?;
let prev = tokens.get(idx.checked_sub(1)?)?;
(source.get(prev.span.start..prev.span.end) == Some(kw)).then_some(prev.span)
}
fn render_state_hover(sig: &str, contextual_kw: &str) -> String {
let doc = bynk_syntax::keywords::CONTEXTUAL_KEYWORDS
.iter()
.find(|k| k.word == contextual_kw)
.map(|k| k.meaning)
.unwrap_or_default();
format!("```bynk\n{sig}\n```\n\n{doc}")
}
pub fn describe_self_at(
text: &str,
offset: usize,
expr_types: &[(Span, bynk_check::checker::Ty)],
) -> Option<String> {
let tokens = tokenize(text).ok()?;
let on_self = tokens.iter().any(|t| {
t.span.start <= offset && offset < t.span.end && &text[t.span.start..t.span.end] == "self"
});
if !on_self {
return None;
}
let ty = bynk_check::expr_types::type_at_offset(expr_types, offset)?;
let display = ty.display();
let name = display
.strip_prefix("__")
.and_then(|s| s.strip_suffix("Self"))
.unwrap_or(&display);
Some(format!("```bynk\nself: {name}\n```"))
}
pub(crate) fn qualified_callee_at(text: &str, ident_span: Span) -> Option<String> {
let before = text.get(..ident_span.start)?.strip_suffix('.')?;
let recv_start = before
.rfind(|c: char| !(c.is_alphanumeric() || c == '_'))
.map_or(0, |i| i + 1);
let recv = &before[recv_start..];
if !recv.chars().next()?.is_uppercase() {
return None;
}
let member = text.get(ident_span.start..ident_span.end)?;
Some(format!("{recv}.{member}"))
}
pub(crate) fn describe_firstparty_symbol(name: &str) -> Option<String> {
const SOURCES: &[&str] = &[
bynk_check::firstparty::BYNK_ADAPTER_SRC,
bynk_check::firstparty::CLOUDFLARE_ADAPTER_SRC,
bynk_check::firstparty::BYNK_LIST_SRC,
bynk_check::firstparty::BYNK_MAP_SRC,
bynk_check::firstparty::BYNK_STRING_SRC,
];
SOURCES.iter().find_map(|src| describe_symbol(src, name))
}
pub(crate) fn unit_reference_spans(source: &str) -> Vec<(String, Span)> {
let Ok(tokens) = tokenize(source) else {
return Vec::new();
};
let (Some(unit), _) = parse_unit_with_recovery(&tokens, source) else {
return Vec::new();
};
let (uses, consumes): (&[UsesDecl], &[ConsumesDecl]) = match &unit {
SourceUnit::Commons(c) => (&c.uses, &[]),
SourceUnit::Context(c) => (&c.uses, &c.consumes),
SourceUnit::Adapter(a) => (&a.uses, &a.consumes),
SourceUnit::Suite(_) => (&[], &[]),
};
let mut out: Vec<(String, Span)> = Vec::new();
for u in uses {
out.push((u.target.joined(), u.target.span));
}
for c in consumes {
out.push((c.target.joined(), c.target.span));
}
out
}
fn describe_item(item: &CommonsItem, name: &str) -> Option<String> {
match item {
CommonsItem::Type(t) if t.name.name == name => Some(describe_type(t)),
CommonsItem::Fn(f) if f.name.ident().name == name => Some(describe_fn(f)),
CommonsItem::Capability(c) if c.name.name == name => Some(describe_capability(c)),
CommonsItem::Service(s) if s.name.name == name => Some(describe_service(s)),
CommonsItem::Agent(a) if a.name.name == name => Some(describe_agent(a)),
CommonsItem::Provider(p) if p.provider_name.name == name => Some(describe_provider(p)),
_ => None,
}
}
fn describe_type(t: &TypeDecl) -> String {
let mut out = String::new();
out.push_str("```bynk\n");
out.push_str(&format!("type {} = ", t.name.name));
match &t.body {
TypeBody::Refined {
base, refinement, ..
} => {
out.push_str(base.name());
if let Some(r) = refinement {
out.push_str(&format!(" where {}", bynk_fmt::refinement_to_string(r)));
}
}
TypeBody::Opaque {
base, refinement, ..
} => {
out.push_str(&format!("opaque {}", base.name()));
if let Some(r) = refinement {
out.push_str(&format!(" where {}", bynk_fmt::refinement_to_string(r)));
}
}
TypeBody::Record(r) => {
if r.fields.is_empty() {
out.push_str("{}");
} else {
out.push_str("{\n");
for f in &r.fields {
out.push_str(&format!("\t{}: {}", f.name.name, type_ref_str(&f.type_ref)));
if let Some(r) = &f.refinement {
out.push_str(&format!(" where {}", bynk_fmt::refinement_to_string(r)));
}
out.push_str(",\n");
}
out.push('}');
}
}
TypeBody::Sum(s) => {
out.push_str("enum {\n");
for v in &s.variants {
out.push_str(&format!("\t{}", v.name.name));
if !v.payload.is_empty() {
let parts: Vec<String> = v
.payload
.iter()
.map(|p| format!("{}: {}", p.name.name, type_ref_str(&p.type_ref)))
.collect();
out.push_str(&format!("({})", parts.join(", ")));
}
out.push_str(",\n");
}
out.push('}');
}
}
out.push_str("\n```\n");
if let Some(doc) = &t.documentation {
out.push('\n');
out.push_str(doc);
out.push('\n');
}
out
}
fn describe_fn(f: &FnDecl) -> String {
let mut out = String::new();
out.push_str("```bynk\n");
out.push_str("fn ");
out.push_str(&f.name.display());
out.push('(');
let mut parts: Vec<String> = Vec::new();
if f.has_self {
parts.push("self".into());
}
for p in &f.params {
parts.push(format!("{}: {}", p.name.name, type_ref_str(&p.type_ref)));
}
out.push_str(&parts.join(", "));
out.push_str(") -> ");
out.push_str(&type_ref_str(&f.return_type));
for c in &f.requires {
out.push_str(&format!(
"\n\trequires {}: {}",
c.name.name,
bynk_fmt::expr_to_string(&c.predicate)
));
}
for c in &f.ensures {
out.push_str(&format!(
"\n\tensures {}: {}",
c.name.name,
bynk_fmt::expr_to_string(&c.predicate)
));
}
out.push_str("\n```\n");
if let Some(doc) = &f.documentation {
out.push('\n');
out.push_str(doc);
out.push('\n');
}
out
}
fn describe_capability(c: &CapabilityDecl) -> String {
let mut out = String::new();
out.push_str("```bynk\ncapability ");
out.push_str(&c.name.name);
out.push_str(" {\n");
for op in &c.ops {
out.push_str("\tfn ");
out.push_str(&op.name.name);
out.push('(');
let parts: Vec<String> = op
.params
.iter()
.map(|p| format!("{}: {}", p.name.name, type_ref_str(&p.type_ref)))
.collect();
out.push_str(&parts.join(", "));
out.push_str(") -> ");
out.push_str(&type_ref_str(&op.return_type));
out.push('\n');
}
out.push_str("}\n```\n");
if let Some(doc) = &c.documentation {
out.push('\n');
out.push_str(doc);
out.push('\n');
}
out
}
fn service_protocol_suffix(p: &ServiceProtocol) -> String {
match p {
ServiceProtocol::Call => String::new(),
ServiceProtocol::Http => " from http".to_string(),
ServiceProtocol::Cron => " from cron".to_string(),
ServiceProtocol::Queue { name } => format!(" from queue(\"{name}\")"),
ServiceProtocol::WebSocket { .. } => " from WebSocket".to_string(),
}
}
fn cors_summary(cors: &CorsPolicy) -> String {
let mut parts = vec![format!("origins: {:?}", cors.origins())];
if cors.credentials() {
parts.push("credentials: true".to_string());
}
if let Some(secs) = cors.max_age_secs() {
parts.push(format!("maxAge: {secs}s"));
}
parts.join(", ")
}
fn security_summary(security: &SecurityPolicy) -> String {
let mut parts = Vec::new();
if !security.nosniff() {
parts.push("nosniff: false".to_string());
}
if let Some(secs) = security.hsts_max_age_secs() {
parts.push(format!("hsts: {secs}s"));
}
if parts.is_empty() {
"nosniff".to_string()
} else {
parts.join(", ")
}
}
fn handler_line(h: &Handler) -> String {
match &h.kind {
HandlerKind::Call => "on call".to_string(),
HandlerKind::Http { method, path } => format!("on {}(\"{}\")", method.as_str(), path),
HandlerKind::Cron { expr } => format!("on schedule(\"{expr}\")"),
HandlerKind::Message => "on message".to_string(),
HandlerKind::Open => "on open".to_string(),
HandlerKind::Close => "on close".to_string(),
}
}
fn describe_service(s: &ServiceDecl) -> String {
let mut out = format!(
"```bynk\nservice {}{} {{\n",
s.name.name,
service_protocol_suffix(&s.protocol)
);
if let Some(cors) = &s.cors {
out.push_str(&format!("\tcors {{ {} }}\n", cors_summary(cors)));
}
if let Some(security) = &s.security {
out.push_str(&format!(
"\tsecurity {{ {} }}\n",
security_summary(security)
));
}
for h in &s.handlers {
out.push_str(&format!("\t{}\n", handler_line(h)));
}
out.push_str("}\n```\n");
if let Some(doc) = &s.documentation {
out.push('\n');
out.push_str(doc);
out.push('\n');
}
out
}
fn store_kind_str(k: &StoreKind) -> String {
if k.args.is_empty() {
k.head.name.clone()
} else {
let args: Vec<String> = k.args.iter().map(type_ref_str).collect();
format!("{}[{}]", k.head.name, args.join(", "))
}
}
fn describe_agent(a: &AgentDecl) -> String {
let mut out = format!(
"```bynk\nagent {} {{\n\tkey {}: {}\n",
a.name.name,
a.key_name.name,
type_ref_str(&a.key_type),
);
for f in &a.store_fields {
out.push_str(&format!(
"\tstore {}: {}\n",
f.name.name,
store_kind_str(&f.kind)
));
}
for inv in &a.invariants {
out.push_str(&format!(
"\tinvariant {}: {}\n",
inv.name.name,
bynk_fmt::expr_to_string(&inv.predicate)
));
}
for tr in &a.transitions {
out.push_str(&format!(
"\ttransition {}: {}\n",
tr.name.name,
bynk_fmt::expr_to_string(&tr.predicate)
));
}
out.push_str("}\n```\n");
if let Some(doc) = &a.documentation {
out.push('\n');
out.push_str(doc);
out.push('\n');
}
out
}
fn describe_provider(p: &ProviderDecl) -> String {
let mut out = format!(
"```bynk\nprovides {} = {}\n```\n",
p.capability.name, p.provider_name.name
);
if let Some(doc) = &p.documentation {
out.push('\n');
out.push_str(doc);
out.push('\n');
}
out
}
pub struct CrossFileSymbol {
pub uri: Url,
pub span: Span,
pub source: String,
}
pub fn find_declaration_cross_file(
src_root: &Path,
current_uri: &Url,
name: &str,
) -> Option<CrossFileSymbol> {
for path in walk_bynk_files(src_root) {
let Ok(uri) = Url::from_file_path(&path) else {
continue;
};
if &uri == current_uri {
continue;
}
let Ok(source) = std::fs::read_to_string(&path) else {
continue;
};
if let Some(span) = find_declaration_span(&source, name) {
return Some(CrossFileSymbol { uri, span, source });
}
}
None
}
pub fn describe_symbol_cross_file(
src_root: &Path,
current_uri: &Url,
name: &str,
) -> Option<(Url, String)> {
for path in walk_bynk_files(src_root) {
let Ok(uri) = Url::from_file_path(&path) else {
continue;
};
if &uri == current_uri {
continue;
}
let Ok(source) = std::fs::read_to_string(&path) else {
continue;
};
if let Some(desc) = describe_symbol(&source, name) {
return Some((uri, desc));
}
}
None
}
pub(crate) fn walk_bynk_files(root: &Path) -> Vec<PathBuf> {
let mut out = Vec::new();
let mut stack = vec![root.to_path_buf()];
while let Some(dir) = stack.pop() {
let Ok(rd) = std::fs::read_dir(&dir) else {
continue;
};
for entry in rd.flatten() {
let p = entry.path();
if p.is_dir() {
stack.push(p);
} else if p.extension().and_then(|e| e.to_str()) == Some("bynk") {
out.push(p);
}
}
}
out.sort();
out
}
pub(crate) fn type_ref_str(t: &TypeRef) -> String {
match t {
TypeRef::Fn(params, ret, _) => {
let lhs = match params.len() {
0 => "()".to_string(),
1 if !matches!(params[0], TypeRef::Fn(..)) => type_ref_str(¶ms[0]),
_ => format!(
"({})",
params
.iter()
.map(type_ref_str)
.collect::<Vec<_>>()
.join(", ")
),
};
format!("{lhs} -> {}", type_ref_str(ret))
}
TypeRef::Base(b, _) => b.name().to_string(),
TypeRef::Named(id) => id.name.clone(),
TypeRef::Result(a, b, _) => format!("Result[{}, {}]", type_ref_str(a), type_ref_str(b)),
TypeRef::Option(t, _) => format!("Option[{}]", type_ref_str(t)),
TypeRef::Effect(t, _) => format!("Effect[{}]", type_ref_str(t)),
TypeRef::HttpResult(t, _) => format!("HttpResult[{}]", type_ref_str(t)),
TypeRef::QueueResult(_) => "QueueResult".to_string(),
TypeRef::List(t, _) => format!("List[{}]", type_ref_str(t)),
TypeRef::Query(t, _) => format!("Query[{}]", type_ref_str(t)),
TypeRef::Stream(t, _) => format!("Stream[{}]", type_ref_str(t)),
TypeRef::Connection(t, _) => format!("Connection[{}]", type_ref_str(t)),
TypeRef::History(t, _) => format!("History[{}]", type_ref_str(t)),
TypeRef::Map(k, v, _) => format!("Map[{}, {}]", type_ref_str(k), type_ref_str(v)),
TypeRef::ValidationError(_) => "ValidationError".to_string(),
TypeRef::JsonError(_) => "JsonError".to_string(),
TypeRef::Unit(_) => "()".to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn setup_project(test_name: &str, files: &[(&str, &str)]) -> PathBuf {
let root = std::env::temp_dir().join(format!(
"bynk-lsp-test-{}-{}",
test_name,
std::process::id()
));
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(&root).expect("create test root");
for (rel, contents) in files {
let p = root.join(rel);
if let Some(parent) = p.parent() {
fs::create_dir_all(parent).expect("create parent");
}
fs::write(&p, contents).expect("write file");
}
root
}
#[test]
fn cross_file_definition_resolves_into_sibling_file() {
let root = setup_project(
"cross_file_definition",
&[
(
"a.bynk",
"commons demo.a\n\ntype Foo = Int where Positive\n",
),
(
"b.bynk",
"commons demo.b\n\nuses demo.a\n\ntype Bar = Int where NonNegative\n",
),
],
);
let current = Url::from_file_path(root.join("b.bynk")).unwrap();
let found = find_declaration_cross_file(&root, ¤t, "Foo")
.expect("Foo should resolve into a.bynk");
let expected = Url::from_file_path(root.join("a.bynk")).unwrap();
assert_eq!(found.uri, expected);
assert!(
found.source.contains("type Foo = Int where Positive"),
"source returned does not contain Foo declaration"
);
}
#[test]
fn cross_file_definition_skips_current_file() {
let root = setup_project(
"cross_file_skip_current",
&[(
"only.bynk",
"commons demo.only\n\ntype Foo = Int where Positive\n",
)],
);
let current = Url::from_file_path(root.join("only.bynk")).unwrap();
assert!(find_declaration_cross_file(&root, ¤t, "Foo").is_none());
}
#[test]
fn cross_file_hover_returns_markdown_summary() {
let root = setup_project(
"cross_file_hover",
&[
(
"money.bynk",
"commons demo.money\n\n\
---\n\
Amount in minor units of currency.\n\
---\n\
type Money = Int where NonNegative\n",
),
(
"orders.bynk",
"commons demo.orders\n\nuses demo.money\n\ntype OrderId = Int where Positive\n",
),
],
);
let current = Url::from_file_path(root.join("orders.bynk")).unwrap();
let (other_uri, desc) = describe_symbol_cross_file(&root, ¤t, "Money")
.expect("Money should produce hover content");
assert_eq!(
other_uri,
Url::from_file_path(root.join("money.bynk")).unwrap()
);
assert!(desc.contains("type Money"));
assert!(
desc.contains("Amount in minor units"),
"hover should include the doc block"
);
}
#[test]
fn cross_file_returns_none_for_unknown_name() {
let root = setup_project(
"cross_file_none",
&[(
"a.bynk",
"commons demo.a\n\ntype Foo = Int where Positive\n",
)],
);
let current = Url::from_file_path(root.join("a.bynk")).unwrap();
assert!(find_declaration_cross_file(&root, ¤t, "DoesNotExist").is_none());
assert!(describe_symbol_cross_file(&root, ¤t, "DoesNotExist").is_none());
}
#[test]
fn first_party_symbols_describe_their_signature_and_doc() {
let reverse = describe_firstparty_symbol("reverse").expect("`bynk.list.reverse` described");
assert!(
reverse.contains("reverse") && reverse.contains("List"),
"{reverse}"
);
assert!(
reverse.contains("reverse order"),
"doc block surfaced: {reverse}"
);
let clock = describe_firstparty_symbol("Clock").expect("`bynk`-surface `Clock`");
assert!(
clock.contains("wall-clock"),
"capability doc surfaced: {clock}"
);
assert!(describe_firstparty_symbol("DoesNotExist").is_none());
}
#[test]
fn unit_reference_spans_finds_uses_and_consumes_targets() {
let src = "context app.main\n uses billing.charge\n consumes platform.time\n";
let spans = unit_reference_spans(src);
let names: Vec<&str> = spans.iter().map(|(n, _)| n.as_str()).collect();
assert!(names.contains(&"billing.charge"), "{names:?}");
assert!(names.contains(&"platform.time"), "{names:?}");
let (_, span) = spans.iter().find(|(n, _)| n == "billing.charge").unwrap();
assert_eq!(&src[span.start..span.end], "billing.charge");
}
#[test]
fn describe_type_renders_fields_variants_and_refinements() {
let record = describe_symbol(
"commons demo.m\n\ntype Order = {\n id: OrderId,\n total: Money,\n}\n",
"Order",
)
.unwrap();
assert!(record.contains("type Order = {"), "{record}");
assert!(record.contains("id: OrderId"), "{record}");
assert!(record.contains("total: Money"), "{record}");
let sum = describe_symbol(
"commons demo.m\n\ntype Status = enum { Pending, Shipped }\n",
"Status",
)
.unwrap();
assert!(sum.contains("enum {"), "{sum}");
assert!(sum.contains("Pending") && sum.contains("Shipped"), "{sum}");
let refined = describe_symbol(
"commons demo.m\n\ntype Email = String where NonEmpty\n",
"Email",
)
.unwrap();
assert!(
refined.contains("type Email = String where NonEmpty"),
"{refined}"
);
let opaque =
describe_symbol("commons demo.m\n\ntype Token = opaque String\n", "Token").unwrap();
assert!(opaque.contains("type Token = opaque String"), "{opaque}");
}
#[test]
fn describe_fn_renders_contracts() {
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";
let out = describe_symbol(src, "discount").unwrap();
assert!(
out.contains("fn discount(p: Int, pct: Int) -> Int"),
"{out}"
);
assert!(out.contains("requires p_nonneg: p >= 0"), "{out}");
assert!(out.contains("ensures never_negative: result >= 0"), "{out}");
}
#[test]
fn describe_service_renders_routes() {
let src = "context demo.app\n\nservice greeter {\n on call(name: String) -> Effect[String] {\n Effect.pure(name)\n }\n}\n";
let out = describe_symbol(src, "greeter").unwrap();
assert!(out.contains("service greeter {"), "{out}");
assert!(out.contains("on call"), "{out}");
}
#[test]
fn describe_agent_renders_store_and_invariants() {
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";
let out = describe_symbol(src, "Counter").unwrap();
assert!(out.contains("agent Counter {"), "{out}");
assert!(out.contains("key id: String"), "{out}");
assert!(out.contains("store count: Cell[Int]"), "{out}");
assert!(out.contains("invariant non_negative: count >= 0"), "{out}");
assert!(
out.contains("transition monotonic: new.count >= old.count"),
"{out}"
);
}
#[test]
fn qualified_callee_detects_upper_receiver_only() {
let text = " let t = Clock.now()";
let now = text.find("now").unwrap();
assert_eq!(
qualified_callee_at(
text,
Span {
start: now,
end: now + 3
}
)
.as_deref(),
Some("Clock.now")
);
let text2 = " xs.fold(0)";
let fold = text2.find("fold").unwrap();
assert!(
qualified_callee_at(
text2,
Span {
start: fold,
end: fold + 4
}
)
.is_none()
);
let text3 = " total";
assert!(qualified_callee_at(text3, Span { start: 2, end: 7 }).is_none());
}
#[test]
fn describe_agent_state_covers_key_store_keywords_and_fields() {
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";
let at_key_kw = src.find("key id").unwrap();
let key_kw = describe_agent_state_at(src, at_key_kw).expect("hover on `key`");
assert!(key_kw.contains("key id: String"), "{key_kw}");
assert!(key_kw.contains("identity field"), "doc line: {key_kw}");
let at_key_name = src.find("id: String").unwrap();
let key_name = describe_agent_state_at(src, at_key_name).expect("hover on the key field");
assert_eq!(key_kw, key_name, "keyword and name hover match");
let at_store_kw = src.find("store items").unwrap();
let store_kw = describe_agent_state_at(src, at_store_kw).expect("hover on `store`");
assert!(
store_kw.contains("store items: Map[String, Int]"),
"{store_kw}"
);
assert!(
store_kw.contains("@indexed(by: id)"),
"annotation: {store_kw}"
);
assert!(
store_kw.contains("@bounded(10000)"),
"annotation: {store_kw}"
);
assert!(
store_kw.contains("persisted agent-state"),
"doc line: {store_kw}"
);
let at_store_name = src.find("items:").unwrap();
let store_name =
describe_agent_state_at(src, at_store_name).expect("hover on the store field");
assert_eq!(store_kw, store_name, "keyword and name hover match");
assert!(describe_agent_state_at(src, src.find("Sessions").unwrap()).is_none());
assert!(describe_agent_state_at(src, src.find("Map").unwrap()).is_none());
assert!(describe_agent_state_at(src, src.find("by: id").unwrap() + 4).is_none());
}
#[test]
fn describe_self_renders_receiver_and_unwraps_agent() {
use bynk_check::checker::{NamedKind, Ty};
let text = "self";
let span = Span { start: 0, end: 4 };
let account = vec![(
span,
Ty::Named {
name: "Account".into(),
kind: NamedKind::Record,
},
)];
assert_eq!(
describe_self_at(text, 0, &account).as_deref(),
Some("```bynk\nself: Account\n```")
);
let agent = vec![(
span,
Ty::Named {
name: "__CounterSelf".into(),
kind: NamedKind::Record,
},
)];
assert_eq!(
describe_self_at(text, 0, &agent).as_deref(),
Some("```bynk\nself: Counter\n```")
);
let other = "total";
assert!(
describe_self_at(
other,
0,
&[(
Span { start: 0, end: 5 },
Ty::Named {
name: "Int".into(),
kind: NamedKind::Record,
},
)]
)
.is_none()
);
}
const CACHE_SVC: &str = "context api\nservice api from http {\n @cache(maxAge: 5.minutes, scope: public)\n on GET(\"/x\") by v: Visitor () -> Effect[HttpResult[String]] {\n Ok(\"y\")\n }\n}\n";
#[test]
fn hover_on_cache_annotation_describes_it() {
let offset = CACHE_SVC.find("cache").unwrap() + 1;
let hover = describe_handler_annotation_at(CACHE_SVC, offset).expect("hovers @cache");
assert!(hover.contains("`@cache`"), "names the annotation: {hover}");
assert!(hover.contains("maxAge"), "documents maxAge: {hover}");
assert!(hover.contains("scope"), "documents scope: {hover}");
let ok_offset = CACHE_SVC.find("Ok(").unwrap() + 1;
assert!(describe_handler_annotation_at(CACHE_SVC, ok_offset).is_none());
}
#[test]
fn annotation_token_spans_cover_name_and_labels() {
let spans = handler_annotation_token_spans(CACHE_SVC);
assert_eq!(spans.len(), 3, "{spans:?}");
let texts: Vec<&str> = spans.iter().map(|s| &CACHE_SVC[s.start..s.end]).collect();
assert_eq!(texts, ["@cache", "maxAge", "scope"]);
let plain = "context api\nservice api from http {\n on GET(\"/x\") by v: Visitor () -> Effect[HttpResult[String]] { Ok(\"y\") }\n}\n";
assert!(handler_annotation_token_spans(plain).is_empty());
}
}