wsb 0.275.7622

Workspace tools for every project under $PRJ: git across submodules, template stamping, GitHub, and the workbench
Documentation
//! `wsb code`'s questions, asked of `omni` at every analysis root of a worktree.
//!
//! Each root is asked on its own, as `omni --json <command> -C <root>`, and every page of the
//! answer is read: what a root says is complete for that root or says why it is not. A root
//! with no analysis is reported as such rather than as one with nothing in it, and a root
//! where the named symbol does not exist is told apart from a root that failed.

use std::path::Path;

use serde_json::Value;

use super::locate::Omni;
use super::roots::AnalysisRoot;

/// How `search` matches.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SearchMode {
    /// The text, literally.
    Literal,
    Regex,
    /// Symbol names, ranked.
    Symbols,
    /// Rust structure, in rust-analyzer's SSR syntax.
    Structural,
}

/// What `wsb code` asks.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Question {
    /// Analyze each root and publish the analysis.
    Index,
    Search { pattern: String, mode: SearchMode, include: Vec<String>, exclude: Vec<String> },
    Symbols { pattern: String, kind: Option<String> },
    Refs { target: String },
    Callers { target: String, depth: u32 },
    Callees { target: String, depth: u32 },
    Diagnostics { file: Option<String> },
}

/// What one root answered.
#[derive(Debug, Clone, PartialEq)]
pub enum Outcome {
    /// `omni index`'s outcome.
    Indexed(Value),
    /// Every result, and what `omni` said about their completeness.
    Results { items: Vec<Value>, status: String, omissions: Vec<Value> },
    /// The question's subject is not in this root (an unknown symbol, say).
    NoMatch { message: String },
    /// This root has no analysis yet.
    NotIndexed,
    /// `omni` could not answer, with its error code.
    Failed { code: String, message: String },
}

impl Outcome {
    /// Whether the root answered the question.
    pub fn answered(&self) -> bool {
        matches!(self, Self::Indexed(_) | Self::Results { .. } | Self::NoMatch { .. })
    }
}

/// One root's answer.
#[derive(Debug, Clone, PartialEq)]
pub struct Answer {
    pub root: AnalysisRoot,
    pub outcome: Outcome,
}

/// Exits after which omni's output is still its answer, qualified by the exit: not found (the
/// status of a directory with no analysis is printed, empty), partial, unknown, stale.
const QUALIFIED_EXITS: [i32; 4] = [3, 10, 11, 12];

/// What a run of `omni --json` printed: its JSON, or its error envelope.
enum Printed {
    Ok(Value),
    Error { code: String, message: String },
}

fn run(omni: &Omni, args: &[String]) -> Result<Printed, String> {
    let out = crate::git_backend::command_without_repo_scope(&omni.path)
        .arg("--json")
        .args(args)
        .output()
        .map_err(|e| format!("cannot run {}: {e}", omni.path.display()))?;
    let text = String::from_utf8_lossy(&out.stdout);
    let value: Value = serde_json::from_str(&text).map_err(|e| {
        format!(
            "`omni {}` did not answer in JSON ({e}): {}",
            args.join(" "),
            String::from_utf8_lossy(&out.stderr).trim()
        )
    })?;
    if out.status.success() {
        return Ok(Printed::Ok(value));
    }
    match (value["code"].as_str(), value["message"].as_str()) {
        (Some(code), Some(message)) => Ok(Printed::Error { code: code.to_owned(), message: message.to_owned() }),
        _ if out.status.code().is_some_and(|c| QUALIFIED_EXITS.contains(&c)) => Ok(Printed::Ok(value)),
        _ => Err(format!("`omni {}` failed ({}) with no error envelope: {value}", args.join(" "), out.status)),
    }
}

/// Every page of a paged question, `next_cursor` to `next_cursor`.
fn pages(omni: &Omni, args: Vec<String>) -> Result<Outcome, String> {
    let mut items = Vec::new();
    let mut omissions = Vec::new();
    let mut status = String::from("complete");
    let mut cursor: Option<String> = None;
    loop {
        let mut page = args.clone();
        if let Some(c) = &cursor {
            page.extend(["--cursor".to_owned(), c.clone()]);
        }
        let v = match run(omni, &page)? {
            Printed::Ok(v) => v,
            Printed::Error { code, message } if code == "not_found" => return Ok(Outcome::NoMatch { message }),
            Printed::Error { code, message } => return Ok(Outcome::Failed { code, message }),
        };
        let results = v["results"]
            .as_array()
            .ok_or_else(|| format!("`omni {}` answered without results: {v}", args.join(" ")))?;
        items.extend(results.iter().cloned());
        omissions.extend(v["omissions"].as_array().cloned().unwrap_or_default());
        // The weakest page decides: one partial page makes the whole answer partial.
        match v["status"].as_str() {
            Some(s) if s != "complete" => status = s.to_owned(),
            _ => {}
        }
        match v["next_cursor"].as_str() {
            Some(next) => cursor = Some(next.to_owned()),
            None => return Ok(Outcome::Results { items, status, omissions }),
        }
    }
}

fn root_arg(root: &AnalysisRoot) -> Vec<String> {
    vec!["-C".to_owned(), root.dir.display().to_string()]
}

/// Whether `root` has a published analysis.
fn indexed(omni: &Omni, root: &AnalysisRoot) -> Result<bool, String> {
    let mut args = vec!["status".to_owned()];
    args.extend(root_arg(root));
    match run(omni, &args)? {
        Printed::Ok(v) => Ok(v["analyses"].as_array().is_some_and(|a| !a.is_empty())),
        Printed::Error { code, message } => Err(format!("`omni status` at {}: {code}: {message}", root.dir.display())),
    }
}

fn ask_one(omni: &Omni, root: &AnalysisRoot, question: &Question) -> Result<Outcome, String> {
    if let Question::Index = question {
        let mut args = vec!["index".to_owned(), root.dir.display().to_string()];
        for g in &root.exclude {
            args.extend(["--exclude".to_owned(), g.clone()]);
        }
        return Ok(match run(omni, &args)? {
            Printed::Ok(v) => Outcome::Indexed(v),
            Printed::Error { code, message } => Outcome::Failed { code, message },
        });
    }
    if !indexed(omni, root)? {
        return Ok(Outcome::NotIndexed);
    }
    let mut args: Vec<String> = match question {
        Question::Index => unreachable!("answered above"),
        Question::Search { pattern, mode, include, exclude } => {
            let mut a = vec!["search".to_owned(), pattern.clone()];
            match mode {
                SearchMode::Literal => {}
                SearchMode::Regex => a.push("--regex".to_owned()),
                SearchMode::Symbols => a.push("--symbols".to_owned()),
                SearchMode::Structural => a.push("--structural".to_owned()),
            }
            for g in include {
                a.extend(["--include".to_owned(), g.clone()]);
            }
            for g in exclude {
                a.extend(["--exclude".to_owned(), g.clone()]);
            }
            a
        }
        Question::Symbols { pattern, kind } => {
            let mut a = vec!["symbols".to_owned(), pattern.clone()];
            if let Some(k) = kind {
                a.extend(["--kind".to_owned(), k.clone()]);
            }
            a
        }
        Question::Refs { target } => vec!["refs".to_owned(), target.clone()],
        Question::Callers { target, depth } => {
            vec!["callers".to_owned(), target.clone(), "--depth".to_owned(), depth.to_string()]
        }
        Question::Callees { target, depth } => {
            vec!["callees".to_owned(), target.clone(), "--depth".to_owned(), depth.to_string()]
        }
        Question::Diagnostics { file } => {
            let mut a = vec!["diagnostics".to_owned()];
            if let Some(f) = file {
                a.extend(["--file".to_owned(), f.clone()]);
            }
            a
        }
    };
    args.extend(root_arg(root));
    match question {
        // Unpaged: the whole answer in one.
        Question::Callers { .. } | Question::Callees { .. } => match run(omni, &args)? {
            Printed::Ok(v) => Ok(Outcome::Results {
                items: v["results"].as_array().cloned().unwrap_or_default(),
                status: v["status"].as_str().unwrap_or("complete").to_owned(),
                omissions: v["omissions"].as_array().cloned().unwrap_or_default(),
            }),
            Printed::Error { code, message } if code == "not_found" => Ok(Outcome::NoMatch { message }),
            Printed::Error { code, message } => Ok(Outcome::Failed { code, message }),
        },
        // A list of diagnostic facts.
        Question::Diagnostics { .. } => match run(omni, &args)? {
            Printed::Ok(Value::Array(items)) => {
                Ok(Outcome::Results { items, status: "complete".to_owned(), omissions: Vec::new() })
            }
            Printed::Ok(other) => Err(format!("`omni diagnostics` answered with no list: {other}")),
            Printed::Error { code, message } => Ok(Outcome::Failed { code, message }),
        },
        _ => pages(omni, args),
    }
}

/// Ask every root; each root's answer, in root order. An error running `omni` itself (no
/// answer at all, not even an error envelope) stops the whole question.
pub fn ask(omni: &Omni, roots: &[AnalysisRoot], question: &Question) -> Result<Vec<Answer>, String> {
    roots
        .iter()
        .map(|root| Ok(Answer { root: root.clone(), outcome: ask_one(omni, root, question)? }))
        .collect()
}

/// Line and column (1-based, in characters) of a UTF-8 byte offset into `path` under `dir`,
/// when the file still holds the content `content_hash` names; `None` when it changed since
/// it was analyzed (a position into other content would point at the wrong place).
pub fn position(dir: &Path, path: &str, content_hash: &str, offset: usize) -> Option<(usize, usize)> {
    let bytes = std::fs::read(dir.join(path)).ok()?;
    let hex = content_hash.strip_prefix("sha256:").unwrap_or(content_hash);
    use sha2::{Digest, Sha256};
    if format!("{:x}", Sha256::digest(&bytes)) != hex {
        return None;
    }
    let text = std::str::from_utf8(&bytes).ok()?;
    let before = text.get(..offset)?;
    let line = before.matches('\n').count() + 1;
    let column = before.rsplit('\n').next().unwrap_or("").chars().count() + 1;
    Some((line, column))
}

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