use std::fmt::Write as _;
use std::sync::Arc;
use std::time::Duration;
use lsp_types::{Position, Range, Url};
use reqwest::Client;
use serde::{Deserialize, Serialize};
use tracing::{debug, warn};
use crate::{
AskRequest, AskResponse, Citation, ENV_ANTHROPIC_KEY, ENV_BACKEND, ENV_ENABLED, ENV_MODEL,
is_runtime_enabled,
};
const DEFAULT_MODEL: &str = "claude-haiku-4-5-20251001";
const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
#[derive(Debug)]
pub enum AdvisorError {
Disabled,
Misconfigured(String),
Transport(String),
Upstream { status: u16, body: String },
Parse(String),
}
impl std::fmt::Display for AdvisorError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Disabled => write!(f, "advisor disabled (set {ENV_ENABLED}=1)"),
Self::Misconfigured(msg) => write!(f, "advisor misconfigured: {msg}"),
Self::Transport(msg) => write!(f, "advisor transport error: {msg}"),
Self::Upstream { status, body } => {
write!(f, "advisor upstream error {status}: {body}")
}
Self::Parse(msg) => write!(f, "advisor response parse error: {msg}"),
}
}
}
impl std::error::Error for AdvisorError {}
pub struct Advisor {
client: Client,
api_key: String,
model: String,
}
impl Advisor {
pub fn from_env() -> Result<Self, AdvisorError> {
if !is_runtime_enabled() {
return Err(AdvisorError::Disabled);
}
let backend = std::env::var(ENV_BACKEND)
.ok()
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "claude".into());
if backend != "claude" {
return Err(AdvisorError::Misconfigured(format!(
"unsupported backend {backend:?} (only `claude` is wired today)"
)));
}
let api_key = std::env::var(ENV_ANTHROPIC_KEY).map_err(|_| {
AdvisorError::Misconfigured(format!(
"{ENV_ANTHROPIC_KEY} is not set; required for backend `claude`"
))
})?;
if api_key.is_empty() {
return Err(AdvisorError::Misconfigured(format!(
"{ENV_ANTHROPIC_KEY} is empty"
)));
}
let model = std::env::var(ENV_MODEL)
.ok()
.filter(|s| !s.is_empty())
.unwrap_or_else(|| DEFAULT_MODEL.into());
let client = Client::builder()
.timeout(REQUEST_TIMEOUT)
.user_agent(concat!("axon-lsp/", env!("CARGO_PKG_VERSION")))
.build()
.map_err(|e| AdvisorError::Transport(e.to_string()))?;
Ok(Self {
client,
api_key,
model,
})
}
pub async fn ask(&self, request: AskRequest) -> Result<AskResponse, AdvisorError> {
let docs = relevant_docs(&request);
let prompt = build_prompt(&request, &docs);
debug!(
chars = prompt.chars().count(),
docs = docs.len(),
"advisor prompt built"
);
let payload = MessagesRequest {
model: &self.model,
max_tokens: 1024,
messages: vec![Message {
role: "user",
content: &prompt,
}],
};
let response = self
.client
.post("https://api.anthropic.com/v1/messages")
.header("x-api-key", &self.api_key)
.header("anthropic-version", "2023-06-01")
.json(&payload)
.send()
.await
.map_err(|e| AdvisorError::Transport(e.to_string()))?;
let status = response.status();
if !status.is_success() {
let body = response.text().await.unwrap_or_default();
warn!(status = %status, "advisor upstream error");
return Err(AdvisorError::Upstream {
status: status.as_u16(),
body,
});
}
let parsed: MessagesResponse = response
.json()
.await
.map_err(|e| AdvisorError::Parse(e.to_string()))?;
let answer = parsed
.content
.into_iter()
.filter(|b| b.kind == "text")
.map(|b| b.text)
.collect::<Vec<_>>()
.join("\n");
let mut citations = synthesise_citations(&answer, &docs);
if let Some(ctx) = &request.context {
citations.push(Citation {
uri: ctx.uri.clone(),
range: ctx.range,
});
}
Ok(AskResponse { answer, citations })
}
}
#[must_use]
pub fn try_advisor_from_env() -> Option<Arc<Advisor>> {
match Advisor::from_env() {
Ok(a) => Some(Arc::new(a)),
Err(AdvisorError::Disabled) => None,
Err(other) => {
warn!(error = %other, "advisor disabled — misconfigured");
None
}
}
}
fn build_prompt(request: &AskRequest, docs: &[&'static lsp_docs::DocEntry]) -> String {
let mut out = String::with_capacity(2048);
out.push_str(
"You are an assistant for the Axon programming language. \
Answer the user's question concisely in Markdown. \
Cite documentation entries by their `name` when relevant. \
If you don't know the answer, say so — don't invent symbols.\n\n",
);
if !docs.is_empty() {
out.push_str("## Grounding documentation\n\n");
for d in docs {
let kind = match d.kind {
lsp_docs::DocKind::Type => "type",
lsp_docs::DocKind::Syntax => "syntax",
lsp_docs::DocKind::Handler => "handler",
};
let _ = write!(out, "### {kind} `{}`\n\n", d.name);
out.push_str(d.body);
out.push_str("\n\n");
}
}
if let Some(ctx) = &request.context
&& !ctx.text.is_empty()
{
out.push_str("## User's selected code\n\n```axon\n");
out.push_str(&ctx.text);
if !ctx.text.ends_with('\n') {
out.push('\n');
}
out.push_str("```\n\n");
}
out.push_str("## Question\n\n");
out.push_str(&request.question);
out.push('\n');
out
}
fn relevant_docs(_request: &AskRequest) -> Vec<&'static lsp_docs::DocEntry> {
lsp_docs::iter_entries().collect()
}
fn synthesise_citations(answer: &str, docs: &[&'static lsp_docs::DocEntry]) -> Vec<Citation> {
let mut out = Vec::new();
for d in docs {
if mentions_word(answer, d.name) {
let kind = match d.kind {
lsp_docs::DocKind::Type => "types",
lsp_docs::DocKind::Syntax => "syntax",
lsp_docs::DocKind::Handler => "handlers",
};
if let Ok(uri) = Url::parse(&format!("axon-lsp://docs/{kind}/{name}.md", name = d.name))
{
out.push(Citation {
uri,
range: Range {
start: Position::new(0, 0),
end: Position::new(0, 0),
},
});
}
}
}
out
}
fn mentions_word(haystack: &str, needle: &str) -> bool {
if needle.is_empty() {
return false;
}
let mut i = 0;
while let Some(found) = haystack[i..].find(needle) {
let abs = i + found;
let before = haystack[..abs].chars().last();
let after = haystack[abs + needle.len()..].chars().next();
let is_word = |c: char| c.is_ascii_alphanumeric() || c == '_';
let left_ok = before.is_none_or(|c| !is_word(c));
let right_ok = after.is_none_or(|c| !is_word(c));
if left_ok && right_ok {
return true;
}
i = abs + needle.len();
}
false
}
#[derive(Serialize)]
struct MessagesRequest<'a> {
model: &'a str,
max_tokens: u32,
messages: Vec<Message<'a>>,
}
#[derive(Serialize)]
struct Message<'a> {
role: &'a str,
content: &'a str,
}
#[derive(Deserialize)]
struct MessagesResponse {
content: Vec<ContentBlock>,
}
#[derive(Deserialize)]
struct ContentBlock {
#[serde(rename = "type")]
kind: String,
#[serde(default)]
text: String,
}
#[cfg(test)]
mod tests {
use super::{build_prompt, mentions_word, synthesise_citations};
use crate::AskRequest;
#[test]
fn build_prompt_includes_grounding_and_question() {
let req = AskRequest {
question: "What is a Trusted<T>?".into(),
context: None,
};
let docs: Vec<&lsp_docs::DocEntry> = lsp_docs::iter_entries().collect();
let prompt = build_prompt(&req, &docs);
assert!(prompt.contains("## Grounding documentation"));
assert!(prompt.contains("type `Trusted`"));
assert!(prompt.contains("## Question"));
assert!(prompt.contains("What is a Trusted<T>?"));
}
#[test]
fn mentions_word_is_word_bounded() {
assert!(mentions_word("the String type", "String"));
assert!(mentions_word("`String`.", "String"));
assert!(!mentions_word("Stringify is unrelated", "String"));
assert!(!mentions_word("xStringy", "String"));
}
#[test]
fn synthesise_citations_skips_unmentioned() {
let docs: Vec<&lsp_docs::DocEntry> = lsp_docs::iter_entries().collect();
let answer = "Use a `Trusted<T>` to carry compliance evidence.";
let cites = synthesise_citations(answer, &docs);
assert!(cites.iter().any(|c| c.uri.as_str().contains("Trusted")));
assert!(!cites.iter().any(|c| c.uri.as_str().contains("String")));
}
}