wsb 0.275.7622

Workspace tools for every project under $PRJ: git across submodules, template stamping, GitHub, and the workbench
Documentation
//! `wsb code`: a question asked of omnicode at every analysis root of the worktree, answered
//! as records and as lines a person reads.

use std::path::{Path, PathBuf};

use serde_json::Value;

use super::locate;
use super::query::{self, Answer, Outcome, Question};
use super::roots::{AnalysisRoot, Fleet};
use crate::output::emit;
use crate::output::record::Record;

/// Ask `question` at every analysis root of the worktree at `worktree`, or of one of its
/// repositories. Every root's answer is shown; an error names the roots that did not answer,
/// so a partial answer is never taken for the whole.
pub fn code(worktree: &Path, repository: Option<&Path>, question: &Question) -> Result<(), String> {
    let omni = locate::find().map_err(|e| e.to_string())?;
    let fleet = Fleet::discover(worktree)?;
    let only = match repository {
        None => None,
        Some(r) => {
            let r = crate::platform::canonicalize(r).map_err(|e| format!("{}: {e}", r.display()))?;
            if !fleet.repositories().contains(&r) {
                return Err(format!(
                    "{} is not a repository of this worktree; its repositories are {}",
                    r.display(),
                    fleet.repositories().iter().map(|p| p.display().to_string()).collect::<Vec<_>>().join(", ")
                ));
            }
            Some(r)
        }
    };
    let roots = fleet.analysis_roots(only.as_deref())?;
    let base = fleet.repositories()[0].clone();
    let answers = query::ask(&omni, &roots, question)?;
    let mut unanswered = Vec::new();
    for answer in &answers {
        let label = label(&base, &answer.root);
        show(&label, answer);
        if !answer.outcome.answered() {
            unanswered.push(label);
        }
    }
    if unanswered.is_empty() {
        return Ok(());
    }
    let not_indexed = answers.iter().any(|a| a.outcome == Outcome::NotIndexed);
    Err(format!(
        "{} of {} analysis roots did not answer: {}{}",
        unanswered.len(),
        answers.len(),
        unanswered.join(", "),
        if not_indexed { " (`wsb code index` analyzes them)" } else { "" }
    ))
}

/// A root as the worktree names it: relative to the worktree's own root.
fn label(base: &Path, root: &AnalysisRoot) -> String {
    match root.dir.strip_prefix(base) {
        Ok(rel) if rel.as_os_str().is_empty() => ".".to_owned(),
        Ok(rel) => rel.to_string_lossy().replace('\\', "/"),
        Err(_) => root.dir.display().to_string(),
    }
}

fn show(label: &str, answer: &Answer) {
    let record = |outcome: &str, status: Option<String>, results: Option<usize>, detail: Option<String>| {
        emit(Record::CodeRoot { root: label.to_owned(), outcome: outcome.to_owned(), status, results, detail })
    };
    match &answer.outcome {
        Outcome::Indexed(v) => {
            let issues: Vec<String> = v["issues"]
                .as_array()
                .map(|a| a.iter().map(|i| i["issue"].as_str().or(i["kind"].as_str()).unwrap_or("issue").to_owned()).collect())
                .unwrap_or_default();
            let status = v["status"].as_str().unwrap_or("indexed").to_owned();
            let detail = (!issues.is_empty()).then(|| issues.join(", "));
            crate::outln!(
                "{label}: {status}{}",
                detail.as_ref().map(|d| format!(" ({d})")).unwrap_or_default()
            );
            record("indexed", Some(status), None, detail);
        }
        Outcome::Results { items, status, omissions } => {
            record("answered", Some(status.clone()), Some(items.len()), None);
            for item in items {
                emit(Record::CodeItem { root: label.to_owned(), item: item.clone() });
                crate::outln!("{}", line(&answer.root.dir, label, item));
            }
            if status != "complete" {
                crate::outln!("{label}: the answer is {status}");
            }
            for o in omissions {
                crate::outln!("{label}: omitted: {}", o["detail"].as_str().or(o["reason"].as_str()).unwrap_or(&o.to_string()));
            }
        }
        Outcome::NoMatch { message } => {
            crate::outln!("{label}: {message}");
            record("no_match", None, Some(0), Some(message.clone()));
        }
        Outcome::NotIndexed => {
            crate::outln!("{label}: not indexed");
            record("not_indexed", None, None, None);
        }
        Outcome::Failed { code, message } => {
            crate::errln!("{label}: {code}: {message}");
            record("failed", None, None, Some(format!("{code}: {message}")));
        }
    }
}

/// `root/path:line:column` for a span omnicode gave, or the byte range when the file changed
/// since it was analyzed.
fn at(dir: &Path, label: &str, span: &Value) -> String {
    let path = span["path"].as_str().unwrap_or("?");
    let shown: PathBuf = if label == "." { PathBuf::from(path) } else { Path::new(label).join(path) };
    let start = span["range_utf8"][0].as_u64().unwrap_or(0) as usize;
    match query::position(dir, path, span["content_hash"].as_str().unwrap_or(""), start) {
        Some((line, column)) => format!("{}:{line}:{column}", shown.display()),
        None => format!("{}@{start} (changed since it was analyzed)", shown.display()),
    }
}

/// One result as a line.
fn line(dir: &Path, label: &str, item: &Value) -> String {
    let symbol = |s: &Value| {
        format!(
            "{}  {} {}{}",
            at(dir, label, &s["location"]),
            s["kind"].as_str().unwrap_or("?"),
            s["path"].as_str().unwrap_or("?"),
            s["signature"].as_str().map(|sig| format!("  {sig}")).unwrap_or_default()
        )
    };
    match item["item"].as_str() {
        Some("symbol") => symbol(item),
        Some("reference") => format!("{}  {}", at(dir, label, &item["span"]), item["role"].as_str().unwrap_or("?")),
        Some("node") => format!(
            "{}  (depth {}, {})",
            symbol(&item["symbol"]),
            item["depth"],
            item["category"].as_str().unwrap_or("?")
        ),
        _ if item["snippet"].is_string() => {
            let path = item["span"]["path"].as_str().unwrap_or("?");
            let shown = if label == "." { path.to_owned() } else { format!("{label}/{path}") };
            format!("{shown}:{}:{}  {}", item["line"], item["column"], item["snippet"].as_str().unwrap_or(""))
        }
        _ if item["symbol"].is_object() && item["rank"].is_number() => symbol(&item["symbol"]),
        _ if item["primary"].is_object() => format!(
            "{}  {} {}: {} [{}]",
            at(dir, label, &item["primary"]),
            item["severity"].as_str().unwrap_or("?"),
            item["code"].as_str().unwrap_or("-"),
            item["message"].as_str().unwrap_or(""),
            item["source"].as_str().unwrap_or("?")
        ),
        _ => format!("{label}: a result this wsb does not know how to show: {item}"),
    }
}

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