use std::cmp::Reverse;
use std::collections::HashMap;
use std::ops::Deref;
use std::time::Duration;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use tokio::time::Instant;
use super::Translator;
use super::dto::{Diagnostic, Location, Position2D, PositionDegradation, Range, Symbol};
use super::routing::{FileLocalCapability, IndexingGate};
use super::symbols::{DocumentSymbolTree, FetchedSymbols, FlatSymbol, symbol_tree};
use crate::config::DocumentLimit;
use crate::error::Error;
use crate::redaction::{Redactions, ServerText};
const MAX_ENRICHED_FILES: usize = 16;
const ENRICHMENT_DEADLINE: Duration = Duration::from_secs(30);
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum ResultContext {
#[default]
None,
EnclosingSymbol,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum SymbolFidelity {
Hierarchical,
Flat,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct EnclosingSymbol {
pub name_path: Vec<String>,
pub kind: u32,
pub range: Range,
pub fidelity: SymbolFidelity,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum NotComputedReason {
FileCap,
OutOfWorkspace,
TrackerLimit,
Deadline,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum UnavailableReason {
CapabilityAbsent,
RequestFailed,
TimedOut,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "status", rename_all = "snake_case")]
pub enum EnclosingSymbolOutcome {
Resolved(EnclosingSymbol),
TopLevel,
NotComputed {
reason: NotComputedReason,
},
Unavailable {
reason: UnavailableReason,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct EnrichmentSummary {
pub files_enriched: usize,
pub files_skipped: usize,
pub cut_short: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[schemars(rename = "Contextual{T}")]
pub struct Contextual<T> {
#[serde(flatten)]
pub inner: T,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(with = "EnclosingSymbolOutcome")]
pub enclosing_symbol: Option<EnclosingSymbolOutcome>,
}
pub type ContextualLocation = Contextual<Location>;
pub type ContextualDiagnostic = Contextual<Diagnostic>;
impl<T> From<T> for Contextual<T> {
fn from(inner: T) -> Self {
Self {
inner,
enclosing_symbol: None,
}
}
}
impl<T> Deref for Contextual<T> {
type Target = T;
fn deref(&self) -> &T {
&self.inner
}
}
pub(super) struct Contextualized<T> {
pub(super) items: Vec<Contextual<T>>,
pub(super) enrichment: Option<EnrichmentSummary>,
pub(super) positions_degraded: Option<PositionDegradation>,
}
pub(super) fn enrichment_file_cap(max_documents: DocumentLimit) -> usize {
max_documents.get().map_or(MAX_ENRICHED_FILES, |max| {
(max.get() / 4).clamp(1, MAX_ENRICHED_FILES)
})
}
#[derive(Clone, Copy)]
enum Containment {
Whole,
Start,
}
const fn key(position: &Position2D) -> (u32, u32) {
(position.line, position.character)
}
impl Containment {
fn admits(self, symbol: &Range, item: &Range) -> bool {
let starts_inside = key(&symbol.start) <= key(&item.start);
match self {
Self::Whole => starts_inside && key(&item.end) <= key(&symbol.end),
Self::Start => starts_inside && key(&item.start) <= key(&symbol.end),
}
}
}
const CONTAINMENT_RULES: [Containment; 2] = [Containment::Whole, Containment::Start];
pub(super) fn innermost_hierarchical(symbols: &[Symbol], item: &Range) -> Option<EnclosingSymbol> {
CONTAINMENT_RULES
.into_iter()
.find_map(|rule| descend(symbols, item, rule))
}
fn descend(symbols: &[Symbol], item: &Range, rule: Containment) -> Option<EnclosingSymbol> {
let mut name_path = Vec::new();
let mut level = symbols;
let mut found: Option<&Symbol> = None;
while let Some(symbol) = level
.iter()
.filter(|symbol| rule.admits(&symbol.range, item))
.max_by_key(|symbol| {
(
key(&symbol.range.start),
Reverse(key(&symbol.range.end)),
Reverse(symbol.name.as_str()),
Reverse(symbol.kind),
)
})
{
name_path.push(symbol.name.clone());
found = Some(symbol);
level = symbol.children.as_deref().unwrap_or_default();
}
found.map(|symbol| EnclosingSymbol {
name_path,
kind: symbol.kind,
range: symbol.range.clone(),
fidelity: SymbolFidelity::Hierarchical,
})
}
pub(super) fn innermost_flat(symbols: &[FlatSymbol], item: &Range) -> Option<EnclosingSymbol> {
CONTAINMENT_RULES.into_iter().find_map(|rule| {
symbols
.iter()
.filter(|flat| rule.admits(&flat.symbol.range, item))
.max_by_key(|flat| {
let symbol = &flat.symbol;
(
key(&symbol.range.start),
Reverse(key(&symbol.range.end)),
Reverse(symbol.name.as_str()),
Reverse(symbol.kind),
)
})
.map(|flat| EnclosingSymbol {
name_path: flat
.container_name
.iter()
.filter(|container| !container.is_empty())
.cloned()
.chain([flat.symbol.name.clone()])
.collect(),
kind: flat.symbol.kind,
range: flat.symbol.range.clone(),
fidelity: SymbolFidelity::Flat,
})
})
}
fn outcome_for(tree: &DocumentSymbolTree, item: &Range) -> EnclosingSymbolOutcome {
let found = match tree {
DocumentSymbolTree::Hierarchical(symbols) => innermost_hierarchical(symbols, item),
DocumentSymbolTree::Flat(symbols) => innermost_flat(symbols, item),
};
found.map_or(
EnclosingSymbolOutcome::TopLevel,
EnclosingSymbolOutcome::Resolved,
)
}
struct Hit<'a> {
uri: &'a str,
range: &'a Range,
}
enum FileResolution {
Tree(DocumentSymbolTree),
Skipped(EnclosingSymbolOutcome),
}
struct Resolution {
outcomes: Vec<EnclosingSymbolOutcome>,
summary: EnrichmentSummary,
positions_degraded: Option<PositionDegradation>,
}
const fn skipped_outcome_for(error: &Error) -> EnclosingSymbolOutcome {
match error {
Error::CapabilityNotSupported { .. } => EnclosingSymbolOutcome::Unavailable {
reason: UnavailableReason::CapabilityAbsent,
},
Error::DocumentLimitExceeded { .. } => EnclosingSymbolOutcome::NotComputed {
reason: NotComputedReason::TrackerLimit,
},
Error::Timeout(_) => EnclosingSymbolOutcome::Unavailable {
reason: UnavailableReason::TimedOut,
},
_ => EnclosingSymbolOutcome::Unavailable {
reason: UnavailableReason::RequestFailed,
},
}
}
const fn not_computed(reason: NotComputedReason) -> FileResolution {
FileResolution::Skipped(EnclosingSymbolOutcome::NotComputed { reason })
}
impl Translator {
pub(super) async fn contextualize_locations(
&self,
locations: Vec<Location>,
context: ResultContext,
positions_degraded: Option<PositionDegradation>,
) -> Contextualized<Location> {
self.contextualize_items(
locations,
&(),
context,
positions_degraded,
|(), location| Hit {
uri: &location.uri,
range: &location.range,
},
)
.await
}
pub(super) async fn contextualize_diagnostics(
&self,
uri: &str,
diagnostics: Vec<Diagnostic>,
context: ResultContext,
positions_degraded: Option<PositionDegradation>,
) -> Contextualized<Diagnostic> {
self.contextualize_items(
diagnostics,
uri,
context,
positions_degraded,
|uri, diagnostic| Hit {
uri,
range: &diagnostic.range,
},
)
.await
}
async fn contextualize_items<T: Send, S: Sync + ?Sized>(
&self,
items: Vec<T>,
shared: &S,
context: ResultContext,
positions_degraded: Option<PositionDegradation>,
hit: impl for<'a> Fn(&'a S, &'a T) -> Hit<'a> + Sync,
) -> Contextualized<T> {
if context == ResultContext::None || items.is_empty() {
return Contextualized {
items: items.into_iter().map(Contextual::from).collect(),
enrichment: None,
positions_degraded,
};
}
let hits: Vec<Hit<'_>> = items.iter().map(|item| hit(shared, item)).collect();
let resolution = self.resolve_enclosing(&hits).await;
let items = items
.into_iter()
.zip(resolution.outcomes)
.map(|(inner, outcome)| Contextual {
inner,
enclosing_symbol: Some(outcome),
})
.collect();
Contextualized {
items,
enrichment: Some(resolution.summary),
positions_degraded: positions_degraded.max(resolution.positions_degraded),
}
}
async fn resolve_enclosing(&self, hits: &[Hit<'_>]) -> Resolution {
let cap = enrichment_file_cap(self.document_tracker.limits().max_documents);
let started = Instant::now();
let mut attempted = 0_usize;
let mut positions_degraded = None;
let mut files: HashMap<&str, FileResolution> = HashMap::new();
for hit in hits {
if files.contains_key(hit.uri) {
continue;
}
let resolution = self
.resolve_file(
hit.uri,
cap,
&mut attempted,
started,
&mut positions_degraded,
)
.await;
files.insert(hit.uri, resolution);
}
let outcomes = hits
.iter()
.map(|hit| match files.get(hit.uri) {
Some(FileResolution::Tree(tree)) => outcome_for(tree, hit.range),
Some(FileResolution::Skipped(outcome)) => outcome.clone(),
None => EnclosingSymbolOutcome::Unavailable {
reason: UnavailableReason::RequestFailed,
},
})
.collect();
let files_enriched = files
.values()
.filter(|file| matches!(file, FileResolution::Tree(_)))
.count();
let cut_short = files.values().any(|file| {
matches!(
file,
FileResolution::Skipped(EnclosingSymbolOutcome::NotComputed {
reason: NotComputedReason::FileCap | NotComputedReason::Deadline
})
)
});
Resolution {
outcomes,
summary: EnrichmentSummary {
files_enriched,
files_skipped: files.len().saturating_sub(files_enriched),
cut_short,
},
positions_degraded,
}
}
async fn resolve_file(
&self,
uri: &str,
cap: usize,
attempted: &mut usize,
started: Instant,
positions_degraded: &mut Option<PositionDegradation>,
) -> FileResolution {
if *attempted >= cap {
return not_computed(NotComputedReason::FileCap);
}
let remaining = ENRICHMENT_DEADLINE.saturating_sub(started.elapsed());
if remaining.is_zero() {
return not_computed(NotComputedReason::Deadline);
}
let Ok(path) = self.parse_file_uri(&lsp_types::Uri::from(uri)).await else {
return not_computed(NotComputedReason::OutOfWorkspace);
};
*attempted = attempted.saturating_add(1);
let lookup = async {
let doc = self
.prepare_gated_document_for_path(
&path,
IndexingGate::FileLocal(FileLocalCapability::DocumentSymbols),
)
.await?;
let FetchedSymbols { doc, ctx, response } = self.fetch_document_symbols(doc).await?;
let tree = symbol_tree(response, &ctx, doc.uri()).await;
Ok((tree, ctx.positions_degraded()))
};
match tokio::time::timeout(remaining, lookup).await {
Err(_elapsed) => FileResolution::Skipped(EnclosingSymbolOutcome::Unavailable {
reason: UnavailableReason::TimedOut,
}),
Ok(Err(error)) => FileResolution::Skipped(skipped_outcome_for(&error)),
Ok(Ok((tree, degraded))) => {
*positions_degraded = (*positions_degraded).max(degraded);
FileResolution::Tree(tree)
}
}
}
}
impl ServerText for EnclosingSymbol {
fn redact_server_text(&mut self, redactions: &Redactions) {
let Self {
name_path,
kind: _,
range: _,
fidelity: _,
} = self;
for segment in name_path {
redactions.note_payload(segment);
}
}
}
impl ServerText for EnclosingSymbolOutcome {
fn redact_server_text(&mut self, redactions: &Redactions) {
match self {
Self::Resolved(symbol) => symbol.redact_server_text(redactions),
Self::TopLevel | Self::NotComputed { reason: _ } | Self::Unavailable { reason: _ } => {}
}
}
}
impl<T: ServerText> ServerText for Contextual<T> {
fn redact_server_text(&mut self, redactions: &Redactions) {
let Self {
inner,
enclosing_symbol,
} = self;
inner.redact_server_text(redactions);
enclosing_symbol.redact_server_text(redactions);
}
}
#[cfg(test)]
mod contextual_tests {
use super::*;
#[test]
fn test_contextual_serializes_flat_for_any_inner_type() {
let item = Contextual {
inner: serde_json::json!({"name": "f"}),
enclosing_symbol: Some(EnclosingSymbolOutcome::TopLevel),
};
let value = serde_json::to_value(&item).unwrap();
assert_eq!(value["name"], "f");
assert_eq!(value["enclosing_symbol"]["status"], "top_level");
let plain = Contextual::from(serde_json::json!({"name": "f"}));
assert!(
serde_json::to_value(&plain)
.unwrap()
.get("enclosing_symbol")
.is_none()
);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_enrichment_file_cap() {
assert_eq!(enrichment_file_cap(DocumentLimit::UNLIMITED), 16);
assert_eq!(enrichment_file_cap(DocumentLimit::new(1)), 1);
assert_eq!(enrichment_file_cap(DocumentLimit::new(3)), 1);
assert_eq!(enrichment_file_cap(DocumentLimit::new(8)), 2);
assert_eq!(enrichment_file_cap(DocumentLimit::new(64)), 16);
assert_eq!(enrichment_file_cap(DocumentLimit::new(10_000)), 16);
}
}
#[cfg(test)]
mod server_text_tests {
use super::*;
#[test]
fn test_enclosing_symbol_name_path_passes_through() {
let secret = "SuperSecretValue123";
let set = Redactions::new([("API_TOKEN".to_owned(), secret.to_owned())]);
let mut outcome = EnclosingSymbolOutcome::Resolved(EnclosingSymbol {
name_path: vec![format!("mod_{secret}"), "f".to_owned()],
kind: 12,
range: Range {
start: Position2D {
line: 1,
character: 1,
},
end: Position2D {
line: 1,
character: 2,
},
},
fidelity: SymbolFidelity::Hierarchical,
});
let before = outcome.clone();
outcome.redact_server_text(&set);
assert_eq!(outcome, before);
}
}