lsp-llm 0.2.1

Opt-in LLM advisor for axon-lsp, gated behind the `llm` Cargo feature. Never on the critical path: deterministic capabilities (diagnostics, hover, completion) work without this crate.
Documentation
//! `lsp-llm` — opt-in LLM advisor.
//!
//! The crate has two faces:
//!
//! - **Always-on surface** ([`AskRequest`], [`AskResponse`],
//!   [`Citation`], [`is_compiled_in`], [`is_runtime_enabled`],
//!   [`is_active`]). These types ship in every build so the
//!   `axon-lsp` Backend can deserialise an `axon/askAdvisor` request
//!   and reply with a structured `MethodNotFound` even when the
//!   advisor itself is absent.
//!
//! - **Feature-gated surface** (`#[cfg(feature = "llm")]`). With
//!   `--features llm` the [`Advisor`] struct is exposed, plus a
//!   `try_advisor_from_env` helper. The advisor depends on
//!   `reqwest` and the embedded `lsp-docs` corpus; both stay out
//!   of the dep tree on default builds.
//!
//! Activation requires **both** axes to be true:
//! - Compile-time: `--features llm` (lights up [`Advisor`]).
//! - Runtime: `AXON_LSP_LLM_ENABLED=1` (the Backend opts in).
//!
//! If either is missing, `axon/askAdvisor` returns `MethodNotFound`
//! with an explanatory message. Diagnostics, hover, completion,
//! and definition keep working at full fidelity — the deterministic
//! stack never depends on this crate at runtime.

#![allow(clippy::module_name_repetitions)]

use lsp_types::{Range, Url};
use serde::{Deserialize, Serialize};

/// Custom LSP method name. Both the server and any LSP client that
/// wants to call the advisor must agree on this string.
pub const METHOD: &str = "axon/askAdvisor";

/// Environment variable that flips the runtime opt-in.
pub const ENV_ENABLED: &str = "AXON_LSP_LLM_ENABLED";

/// Environment variable that selects the backend. Today only
/// `claude` is wired; the slot exists so future backends can plug in
/// without a schema change.
pub const ENV_BACKEND: &str = "AXON_LLM_BACKEND";

/// Environment variable that overrides the default Anthropic model.
pub const ENV_MODEL: &str = "AXON_LLM_MODEL";

/// Environment variable carrying the Anthropic API key when
/// `AXON_LLM_BACKEND=claude`.
pub const ENV_ANTHROPIC_KEY: &str = "ANTHROPIC_API_KEY";

/// Optional code context the client can attach to a request.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CodeContext {
    pub uri: Url,
    pub range: Range,
    /// The actual text of the selected range. The client extracts
    /// it from the document so the server doesn't have to re-derive
    /// it from rope coordinates.
    #[serde(default)]
    pub text: String,
}

/// JSON-RPC request payload for `axon/askAdvisor`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AskRequest {
    pub question: String,
    #[serde(default)]
    pub context: Option<CodeContext>,
}

/// One citation accompanying an answer. Today the URI is a
/// synthetic `axon-lsp://docs/{kind}/{name}.md` for documentation
/// entries, or the source URI when the advisor cited the user's
/// own code. Editors may not be able to navigate the synthetic
/// scheme, but the data is structured for future routing.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Citation {
    pub uri: Url,
    pub range: Range,
}

/// JSON-RPC response payload for `axon/askAdvisor`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AskResponse {
    pub answer: String,
    #[serde(default)]
    pub citations: Vec<Citation>,
}

/// True when the binary was compiled with `--features llm`.
#[must_use]
pub const fn is_compiled_in() -> bool {
    cfg!(feature = "llm")
}

/// True when the runtime opt-in env var is set to `1`. Reads the
/// process environment on every call — the advisor is rare enough
/// (one call per user question) that caching adds no value.
#[must_use]
pub fn is_runtime_enabled() -> bool {
    std::env::var(ENV_ENABLED).as_deref() == Ok("1")
}

/// Whether the advisor is fully active — both compile-time and
/// runtime gates must say yes. The Backend uses this to decide
/// between routing through the advisor and replying with
/// `MethodNotFound`.
#[must_use]
pub fn is_active() -> bool {
    is_compiled_in() && is_runtime_enabled()
}

#[cfg(feature = "llm")]
mod advisor;

#[cfg(feature = "llm")]
pub use advisor::{Advisor, AdvisorError, try_advisor_from_env};

#[cfg(test)]
mod tests {
    // Note on env-var tests: Rust 2024 marks `std::env::set_var` /
    // `remove_var` as `unsafe`, and the workspace forbids unsafe
    // code. Runtime-flag round-tripping is covered end-to-end by
    // `crates/axon-lsp/tests/lsp_smoke.rs`, where the child process
    // gets a controlled environment via `Command::env`. This module
    // sticks to thread-safe checks — pure flag arithmetic and
    // serialisation round-trips.

    #[cfg(not(feature = "llm"))]
    use super::is_active;
    use super::{AskRequest, is_compiled_in};
    use serde_json::json;

    #[test]
    fn flags_match_cfg_default() {
        assert_eq!(is_compiled_in(), cfg!(feature = "llm"));
    }

    #[cfg(not(feature = "llm"))]
    #[test]
    fn default_build_is_inactive() {
        // Compile-time gate alone is enough to keep us inactive.
        assert!(!is_active());
    }

    #[test]
    fn ask_request_round_trips_json() {
        let req: AskRequest = serde_json::from_value(json!({
            "question": "What is a Trusted<T>?",
            "context": null,
        }))
        .expect("deserialise");
        assert_eq!(req.question, "What is a Trusted<T>?");
        assert!(req.context.is_none());
        let v = serde_json::to_value(&req).expect("serialise");
        assert_eq!(v["question"], "What is a Trusted<T>?");
    }
}