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::Test(_) | SourceUnit::Integration(_) => &[],
};
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::Test(_) | SourceUnit::Integration(_) => &[],
};
for item in items {
if let Some(summary) = describe_item(item, name) {
return Some(summary);
}
}
None
}
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::Test(_) | SourceUnit::Integration(_) => (&[], &[]),
};
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");
let body = match &t.body {
TypeBody::Refined { base, .. } => format!("type {} = {}", t.name.name, base.name()),
TypeBody::Opaque { base, .. } => format!("type {} = opaque {}", t.name.name, base.name()),
TypeBody::Record(_) => format!("type {} = record", t.name.name),
TypeBody::Sum(_) => format!("type {} = sum", t.name.name),
};
out.push_str(&body);
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));
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 describe_service(s: &ServiceDecl) -> String {
let mut out = format!("```bynk\nservice {}\n```\n", s.name.name);
if let Some(doc) = &s.documentation {
out.push('\n');
out.push_str(doc);
out.push('\n');
}
out.push_str(&format!("\n{} handler(s).", s.handlers.len()));
out
}
fn describe_agent(a: &AgentDecl) -> String {
let mut out = format!(
"```bynk\nagent {} {{\n\tkey {}: {}\n\tstate {{ {} field(s) }}\n}}\n```\n",
a.name.name,
a.key_name.name,
type_ref_str(&a.key_type),
a.state_fields.len(),
);
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::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");
}
}