use anyhow::Result;
use serde::Serialize;
use crate::{Filter, Hit, Prefer, fleet::Fleet, store::EdgeEnd};
pub const DEFAULT_BUDGET: i64 = 4000;
pub const SPANS: usize = 8;
const EDGES_PER_SYMBOL: usize = 8;
const TEXT_SPANS: usize = 1;
#[derive(Debug, Clone, Serialize)]
pub struct Brief {
pub question: String,
pub budget: i64,
pub tokens: i64,
pub counted_with: &'static str,
pub floor: i64,
pub spans: Vec<Span>,
pub symbols: Vec<Symbol>,
pub cut: Cut,
}
#[derive(Debug, Clone, Serialize)]
pub struct Span {
#[serde(serialize_with = "crate::serialize_plain")]
pub path: String,
pub start_line: u32,
pub end_line: u32,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub lists: Vec<&'static str>,
#[serde(skip_serializing_if = "Option::is_none")]
pub symbol: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub symbol_kind: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub symbol_line: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub store: Option<String>,
pub fresh: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub text: Option<String>,
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub over_budget: bool,
}
#[derive(Debug, Clone, Serialize)]
pub struct Symbol {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub store: Option<String>,
pub found_by: &'static str,
pub callers: Vec<EdgeEnd>,
pub callees: Vec<EdgeEnd>,
#[serde(skip_serializing_if = "is_zero")]
pub hidden: usize,
}
#[derive(Debug, Clone, Default, Serialize)]
pub struct Cut {
#[serde(skip_serializing_if = "is_zero")]
pub spans: usize,
#[serde(skip_serializing_if = "is_zero")]
pub span_text: usize,
#[serde(skip_serializing_if = "is_zero")]
pub symbols: usize,
#[serde(skip_serializing_if = "is_zero")]
pub edges: usize,
}
fn is_zero(n: &usize) -> bool {
*n == 0
}
impl Cut {
pub fn is_empty(&self) -> bool {
self.spans == 0 && self.span_text == 0 && self.symbols == 0 && self.edges == 0
}
}
pub fn brief(
fleet: &mut Fleet,
only: Option<&[String]>,
question: &str,
budget: i64,
filter: &Filter,
prefer: Prefer,
) -> Result<Brief> {
brief_leaning(fleet, only, question, budget, filter, Some(prefer))
}
pub fn brief_leaning(
fleet: &mut Fleet,
only: Option<&[String]>,
question: &str,
budget: i64,
filter: &Filter,
prefer: Option<Prefer>,
) -> Result<Brief> {
let hits = fleet.search_leaning(only, question, SPANS, filter, prefer)?;
let counter = fleet.counter();
let counted_with = counter.label();
let paths = fleet.shortener();
let mut tokens: i64 = 0;
let mut cut = Cut::default();
let cost_of =
|i: usize| counter.count(&Span::locating(&hits[i]).locator(&paths.short(&hits[i].path)));
let code = crate::code_shaped(question);
let has_text = |i: usize| hits[i].image.is_none();
let texted: Option<usize> = if hits.is_empty() {
None
} else if code {
let product =
|i: usize| crate::filter::is_code(&hits[i].path) && !crate::is_test_path(&hits[i].path);
let about_tests = question.to_lowercase().contains("test");
let any = 0..hits.len();
any.clone()
.find(|&i| product(i) || (about_tests && crate::filter::is_code(&hits[i].path)))
.or_else(|| any.clone().find(|&i| crate::filter::is_code(&hits[i].path)))
.or_else(|| any.clone().find(|&i| has_text(i)))
.or(Some(0))
} else {
(0..hits.len()).find(|&i| has_text(i)).or(Some(0))
};
let mut bought: Vec<usize> = Vec::new();
let floor = if hits.is_empty() { 0 } else { cost_of(0) };
if !hits.is_empty() {
tokens += floor;
bought.push(0);
}
let anchored = match texted {
Some(0) => true,
Some(t) if tokens + cost_of(t) <= budget => {
tokens += cost_of(t);
bought.push(t);
true
}
_ => false,
};
let navigational = |kind: &str| crate::graph::NAVIGATIONAL_KINDS.contains(&kind);
let mut symbols: Vec<Symbol> = Vec::new();
let mut seen: Vec<String> = Vec::new();
for hit in texted
.filter(|_| anchored)
.into_iter()
.take(TEXT_SPANS)
.map(|t| &hits[t])
{
let Some(name) = hit.symbol.as_deref() else {
continue;
};
if code && hit.symbol_kind.as_deref().is_some_and(navigational) {
continue;
}
if seen.iter().any(|s| s == name) {
continue;
}
seen.push(name.to_string());
let own = hit.store.as_ref().map(std::slice::from_ref);
let here = hit.symbol_line.and_then(|line| {
fleet
.symbols_in(own.or(only), name, 200)
.ok()?
.into_iter()
.find(|row| row.path == hit.path && row.start_line == line)
});
let asked = match here
.as_ref()
.and_then(|row| crate::store::owner_of(&row.qualified))
{
Some(owner) => format!("{owner}::{name}"),
None => name.to_string(),
};
let mut found = fleet.neighbours_in(own.or(only), &asked, &[], false, filter)?;
if let Some(row) = &here {
found.callees.retain(|e| {
e.from_path.as_deref().is_none_or(|p| p == row.path)
&& e.from_line.is_none_or(|l| l == row.start_line)
});
}
if code {
found.callers.retain(|e| !navigational(&e.symbol.kind));
found.callees.retain(|e| !navigational(&e.symbol.kind));
}
once(&mut found.callers);
once(&mut found.callees);
let (callers, hidden_callers) = cap(found.callers);
let (callees, hidden_callees) = cap(found.callees);
if callers.is_empty() && callees.is_empty() {
continue;
}
let mut symbol = Symbol {
name: name.to_string(),
store: hit.store.clone(),
found_by: "graph",
callers: Vec::new(),
callees: Vec::new(),
hidden: hidden_callers + hidden_callees,
};
let head = counter.count(&symbol.name);
if tokens + head > budget {
cut.symbols += 1;
cut.edges += callers.len() + callees.len();
continue;
}
let mut spent = head;
let edges = callers
.into_iter()
.map(|e| (true, e))
.chain(callees.into_iter().map(|e| (false, e)));
for (calling, edge) in edges {
let cost = counter.count(&edge_row(&edge, &paths));
if tokens + spent + cost > budget {
cut.edges += 1;
continue;
}
spent += cost;
if calling {
symbol.callers.push(edge);
} else {
symbol.callees.push(edge);
}
}
if symbol.callers.is_empty() && symbol.callees.is_empty() {
cut.symbols += 1;
continue;
}
tokens += spent;
symbols.push(symbol);
}
for i in 0..hits.len() {
if bought.contains(&i) {
continue;
}
let cost = cost_of(i);
if tokens + cost > budget {
cut.spans += 1;
continue;
}
tokens += cost;
bought.push(i);
}
bought.sort_unstable();
let mut spans: Vec<Span> = bought.iter().map(|&i| Span::locating(&hits[i])).collect();
let edges_cut = cut.edges > 0 || cut.symbols > 0;
if let Some(t) = texted.filter(|_| anchored)
&& let Some(at) = bought.iter().position(|&i| i == t)
{
let cost = counter.count(&hits[t].text);
if edges_cut || tokens + cost > budget {
cut.span_text += 1;
spans[at].over_budget = true;
} else {
tokens += cost;
spans[at].text = Some(hits[t].text.clone());
}
}
Ok(Brief {
question: question.to_string(),
budget,
tokens,
counted_with,
floor,
spans,
symbols,
cut,
})
}
fn once(ends: &mut Vec<EdgeEnd>) {
let mut seen: Vec<(String, String, u32)> = Vec::new();
ends.retain(|e| {
let key = (
e.symbol.name.clone(),
e.symbol.path.clone(),
e.symbol.start_line,
);
let first = !seen.contains(&key);
seen.push(key);
first
});
}
fn cap(mut edges: Vec<EdgeEnd>) -> (Vec<EdgeEnd>, usize) {
let hidden = edges.len().saturating_sub(EDGES_PER_SYMBOL);
edges.truncate(EDGES_PER_SYMBOL);
(edges, hidden)
}
impl Span {
fn locating(hit: &Hit) -> Self {
Self {
path: hit.path.clone(),
start_line: hit.start_line,
end_line: hit.end_line,
lists: hit.lists.clone(),
symbol: hit.symbol.clone(),
symbol_kind: hit.symbol_kind.clone(),
symbol_line: hit.symbol_line,
over_budget: false,
store: hit.store.clone(),
fresh: hit.fresh,
text: None,
}
}
pub fn range(&self) -> String {
if self.start_line == 0 && self.end_line == 0 {
String::new()
} else {
format!(":{}-{}", self.start_line, self.end_line)
}
}
fn locator(&self, shown: &str) -> String {
let mut line = format!("{shown}{}", self.range());
if let Some(symbol) = &self.symbol {
line.push(' ');
line.push_str(symbol);
}
if let Some(kind) = &self.symbol_kind {
line.push(' ');
line.push_str(kind);
}
for list in &self.lists {
line.push(' ');
line.push_str(list);
}
line
}
}
fn edge_row(edge: &EdgeEnd, paths: &crate::Shortener) -> String {
format!(
"{} {} {} {}",
edge.symbol.name,
paths.short(&edge.symbol.path),
edge.kind,
edge.confidence
)
}
pub fn untexted_note(spans: &[Span]) -> String {
match spans.iter().position(|s| s.text.is_some()) {
Some(at) => format!("(locator only; span {} carries the text)", at + 1),
None => "(locator only)".to_string(),
}
}
pub fn dropped(cut: &Cut) -> Vec<String> {
let plural =
|n: usize, one: &str, many: &str| format!("{n} {}", if n == 1 { one } else { many });
let mut out = Vec::new();
if cut.spans > 0 {
out.push(plural(cut.spans, "locator", "locators"));
}
if cut.symbols > 0 {
out.push(plural(cut.symbols, "symbol's edges", "symbols' edges"));
}
if cut.edges > 0 {
out.push(plural(cut.edges, "edge", "edges"));
}
if cut.span_text > 0 {
out.push(plural(cut.span_text, "span's text", "spans' text"));
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_drop_is_named() {
let cut = Cut {
spans: 5,
span_text: 1,
symbols: 1,
edges: 2,
};
assert_eq!(
dropped(&cut),
["5 locators", "1 symbol's edges", "2 edges", "1 span's text"]
);
assert!(dropped(&Cut::default()).is_empty());
}
#[test]
fn a_span_without_text_names_the_one_with_it() {
let span = |text: Option<&str>, start: u32| Span {
path: "a.png".into(),
start_line: start,
end_line: start,
lists: Vec::new(),
symbol: None,
symbol_kind: None,
symbol_line: None,
store: None,
fresh: true,
text: text.map(str::to_string),
over_budget: false,
};
let spans = [span(None, 0), span(None, 3), span(Some("x"), 7)];
assert_eq!(
untexted_note(&spans),
"(locator only; span 3 carries the text)"
);
assert_eq!(untexted_note(&spans[..2]), "(locator only)");
assert_eq!(spans[0].range(), "");
assert_eq!(spans[0].locator("a.png"), "a.png");
assert_eq!(spans[1].range(), ":3-3");
}
}