wsb 0.275.7622

Workspace tools for every project under $PRJ: git across submodules, template stamping, GitHub, and the workbench
Documentation
//! Where `omni` is, and what it answers for.
//!
//! One rule: the first executable named `omni` on this process's `PATH`. For the daemon run as
//! a service that is the `PATH` the service was installed with, so an `omni` installed later is
//! found by a daemon started from a shell, and by a service once it is reinstalled. Looked up
//! again each time it is needed: installing it needs no restart of anything.
//!
//! What it answers for comes from `omni lsp --capabilities`: the revision of what it
//! advertises, every language with its file extensions and the features it is served with,
//! and the semantic-token legend. A revision older than [`REQUIRED_REVISION`] is refused by
//! name rather than half used.

use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::Mutex;

use serde::{Deserialize, Serialize};

/// The oldest revision of `experimental.omnicode` this wsb reads.
pub const REQUIRED_REVISION: u32 = 1;

/// Why there is no `omni` to use.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "state", rename_all = "snake_case")]
pub enum Unavailable {
    /// No executable named `omni` on `PATH`.
    NotInstalled { searched: Vec<PathBuf> },
    /// An `omni` older (or other) than this wsb works with.
    Incompatible { path: PathBuf, detail: String },
    /// It is there and does not say what it answers for.
    Broken { path: PathBuf, detail: String },
}

impl std::fmt::Display for Unavailable {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::NotInstalled { searched } => write!(
                f,
                "omnicode's `omni` is not installed: no `omni` in {}. Install it and put it on PATH; a \
                 daemon run as a service has the PATH `wsb daemon install` gave it, so reinstall the \
                 service after installing omni",
                if searched.is_empty() {
                    "an empty PATH".to_owned()
                } else {
                    searched.iter().map(|p| p.display().to_string()).collect::<Vec<_>>().join(", ")
                }
            ),
            Self::Incompatible { path, detail } => write!(f, "{} cannot be used: {detail}", path.display()),
            Self::Broken { path, detail } => write!(f, "{} does not say what it answers for: {detail}", path.display()),
        }
    }
}

/// A language `omni` answers for.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Language {
    pub extensions: Vec<String>,
    /// What files of it are served with (`definition`, `hover`, `diagnostics`, ...).
    pub features: Vec<String>,
}

/// The semantic-token vocabulary, in the order token data indexes it.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Legend {
    pub token_types: Vec<String>,
    pub token_modifiers: Vec<String>,
}

/// An `omni` this wsb can work with.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Omni {
    pub path: PathBuf,
    /// What `omni --version` says.
    pub version: String,
    pub revision: u32,
    /// By language id.
    pub languages: BTreeMap<String, Language>,
    pub legend: Legend,
    /// Characters after which the server offers completion and signature help.
    pub completion_triggers: Vec<String>,
    pub signature_triggers: Vec<String>,
}

impl Omni {
    /// The language a file is, by its extension.
    pub fn language_of(&self, path: &Path) -> Option<(&str, &Language)> {
        let ext = path.extension()?.to_str()?.to_ascii_lowercase();
        self.languages.iter().find(|(_, l)| l.extensions.contains(&ext)).map(|(id, l)| (id.as_str(), l))
    }
}

/// The `omni` on `PATH`, and what it answers for.
pub fn find() -> Result<Omni, Unavailable> {
    describe(&locate()?)
}

/// The first `omni` on `PATH`.
pub fn locate() -> Result<PathBuf, Unavailable> {
    match crate::report::which("omni") {
        Some(p) => Ok(PathBuf::from(p)),
        None => Err(Unavailable::NotInstalled {
            searched: std::env::var_os("PATH").map(|p| std::env::split_paths(&p).collect()).unwrap_or_default(),
        }),
    }
}

/// What the `omni` at `path` answers for. Asked once per build of it: the answer is kept
/// while the file is the same.
pub fn describe(path: &Path) -> Result<Omni, Unavailable> {
    static SEEN: Mutex<Option<(PathBuf, std::time::SystemTime, u64, Omni)>> = Mutex::new(None);
    let broken = |detail: String| Unavailable::Broken { path: path.to_owned(), detail };
    let meta = std::fs::metadata(path).map_err(|e| broken(e.to_string()))?;
    let stamp = (meta.modified().map_err(|e| broken(e.to_string()))?, meta.len());
    if let Some((p, modified, len, omni)) = SEEN.lock().unwrap_or_else(std::sync::PoisonError::into_inner).as_ref() {
        if p == path && (*modified, *len) == stamp {
            return Ok(omni.clone());
        }
    }
    let run = |args: &[&str]| -> Result<String, Unavailable> {
        let out = crate::git_backend::command_without_repo_scope(path)
            .args(args)
            .output()
            .map_err(|e| broken(format!("cannot run it: {e}")))?;
        if !out.status.success() {
            return Err(broken(format!(
                "`omni {}` failed ({}): {}",
                args.join(" "),
                out.status,
                String::from_utf8_lossy(&out.stderr).trim()
            )));
        }
        Ok(String::from_utf8_lossy(&out.stdout).into_owned())
    };
    let version = run(&["--version"])?.trim().to_owned();
    let omni = parse(path, &version, &run(&["lsp", "--capabilities"])?)?;
    *SEEN.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = Some((path.to_owned(), stamp.0, stamp.1, omni.clone()));
    Ok(omni)
}

/// What an `initialize` result says, as `omni lsp --capabilities` prints it.
pub fn parse(path: &Path, version: &str, initialize: &str) -> Result<Omni, Unavailable> {
    let broken = |detail: String| Unavailable::Broken { path: path.to_owned(), detail };
    let v: serde_json::Value = serde_json::from_str(initialize).map_err(|e| broken(format!("not JSON: {e}")))?;
    if v["serverInfo"]["name"] != "omnicode" {
        return Err(Unavailable::Incompatible {
            path: path.to_owned(),
            detail: format!("it is not omnicode's language server (serverInfo {})", v["serverInfo"]),
        });
    }
    let caps = &v["capabilities"];
    let marker = &caps["experimental"]["omnicode"];
    let Some(revision) = marker["revision"].as_u64() else {
        return Err(Unavailable::Incompatible {
            path: path.to_owned(),
            detail: format!(
                "it does not say what it answers for (no experimental.omnicode revision); this wsb needs revision {REQUIRED_REVISION} or later"
            ),
        });
    };
    let revision = u32::try_from(revision).map_err(|_| broken(format!("revision {revision} is out of range")))?;
    if revision < REQUIRED_REVISION {
        return Err(Unavailable::Incompatible {
            path: path.to_owned(),
            detail: format!("it speaks revision {revision}; this wsb needs {REQUIRED_REVISION} or later"),
        });
    }
    let languages: BTreeMap<String, Language> = serde_json::from_value(marker["languages"].clone())
        .map_err(|e| broken(format!("experimental.omnicode.languages: {e}")))?;
    let legend: Legend = serde_json::from_value(serde_json::json!({
        "token_types": caps["semanticTokensProvider"]["legend"]["tokenTypes"],
        "token_modifiers": caps["semanticTokensProvider"]["legend"]["tokenModifiers"],
    }))
    .map_err(|e| broken(format!("semantic token legend: {e}")))?;
    let strings = |v: &serde_json::Value| -> Vec<String> {
        v.as_array().map(|a| a.iter().filter_map(|s| s.as_str().map(str::to_owned)).collect()).unwrap_or_default()
    };
    if caps["positionEncoding"] != "utf-16" {
        return Err(Unavailable::Incompatible {
            path: path.to_owned(),
            detail: format!("it offers position encoding {}; this wsb speaks utf-16", caps["positionEncoding"]),
        });
    }
    Ok(Omni {
        path: path.to_owned(),
        version: version.to_owned(),
        revision,
        languages,
        legend,
        completion_triggers: strings(&caps["completionProvider"]["triggerCharacters"]),
        signature_triggers: strings(&caps["signatureHelpProvider"]["triggerCharacters"]),
    })
}

#[cfg(test)]
#[path = "locate_tests.rs"]
mod tests;