use std::path::Path;
use crate::commands::outline::{
build_outline_tree, format_entry_with_sig, symbol_to_entry, OutlineEntry,
};
use crate::context::AppContext;
use crate::parser::LangId;
use crate::symbols::{Range, Symbol, SymbolKind};
pub const LARGE_CONTAINER_MENU_LINE_THRESHOLD: usize = 150;
pub struct ContainerOutline {
entry: OutlineEntry,
symbols: Vec<Symbol>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BudgetedSymbolRenderStatus {
Complete,
Truncated,
Menu,
}
pub struct BudgetedSymbolRender {
pub content: String,
pub status: BudgetedSymbolRenderStatus,
}
pub fn symbol_kind_string(kind: &SymbolKind) -> String {
serde_json::to_value(kind)
.ok()
.and_then(|value| value.as_str().map(String::from))
.unwrap_or_else(|| format!("{kind:?}").to_lowercase())
}
pub fn qualified_symbol_name(symbol: &Symbol) -> String {
let mut parts = symbol
.scope_chain
.iter()
.filter(|part| !part.is_empty())
.cloned()
.collect::<Vec<_>>();
parts.push(symbol.name.clone());
parts.join(".")
}
pub fn might_have_container_members(symbol: &Symbol) -> bool {
matches!(
&symbol.kind,
SymbolKind::Class
| SymbolKind::Struct
| SymbolKind::Interface
| SymbolKind::Enum
| SymbolKind::Variable
| SymbolKind::TypeAlias
)
}
fn is_container_kind(kind: &SymbolKind) -> bool {
matches!(
kind,
SymbolKind::Class | SymbolKind::Struct | SymbolKind::Interface | SymbolKind::Enum
)
}
pub fn build_container_outline(
ctx: &AppContext,
resolved_file_path: &Path,
target: &Symbol,
) -> Result<ContainerOutline, crate::error::AftError> {
let symbols = ctx.provider().list_symbols(resolved_file_path)?;
let entries = build_outline_tree(&symbols);
let entry = find_outline_entry(&entries, target)
.cloned()
.unwrap_or_else(|| symbol_to_entry(target));
Ok(ContainerOutline { entry, symbols })
}
fn find_outline_entry<'a>(
entries: &'a [OutlineEntry],
target: &Symbol,
) -> Option<&'a OutlineEntry> {
for entry in entries {
if entry.name == target.name && entry.range == target.range {
return Some(entry);
}
if let Some(found) = find_outline_entry(&entry.members, target) {
return Some(found);
}
}
None
}
pub fn should_return_member_menu(
target: &Symbol,
lang: Option<LangId>,
outline: Option<&ContainerOutline>,
) -> bool {
let Some(outline) = outline else {
return false;
};
let is_container = is_container_kind(&target.kind) || !outline.entry.members.is_empty();
if !is_container {
return false;
}
container_rendered_line_count(target, lang, &outline.entry)
> LARGE_CONTAINER_MENU_LINE_THRESHOLD
}
fn range_line_count(range: &Range) -> usize {
range
.end_line
.saturating_sub(range.start_line)
.saturating_add(1) as usize
}
fn range_contains(outer: &Range, inner: &Range) -> bool {
(outer.start_line, outer.start_col) <= (inner.start_line, inner.start_col)
&& (outer.end_line, outer.end_col) >= (inner.end_line, inner.end_col)
}
fn container_rendered_line_count(
target: &Symbol,
lang: Option<LangId>,
entry: &OutlineEntry,
) -> usize {
let mut line_count = range_line_count(&target.range);
if lang == Some(LangId::Rust) {
for member in &entry.members {
if !range_contains(&target.range, &member.range) {
line_count = line_count.saturating_add(range_line_count(&member.range));
}
}
}
line_count
}
pub fn render_container_member_menu(target: &Symbol, outline: &ContainerOutline) -> String {
let kind = symbol_kind_string(&target.kind);
let qualified_name = qualified_symbol_name(target);
let member_count = outline.entry.members.len();
let mut lines = vec![format!(
"{kind} {qualified_name} ({member_count} members) — member-signature menu; zoom a member for its body"
)];
lines.push(format_qualified_entry(&outline.entry, Some(target)));
if outline.entry.members.is_empty() {
lines.push(" (no direct members found)".to_string());
} else {
for member in &outline.entry.members {
let symbol = find_symbol_for_entry(&outline.symbols, member);
lines.push(format!(" .{}", format_qualified_entry(member, symbol)));
}
}
lines.join("\n")
}
fn find_symbol_for_entry<'a>(symbols: &'a [Symbol], entry: &OutlineEntry) -> Option<&'a Symbol> {
symbols
.iter()
.find(|symbol| symbol.name == entry.name && symbol.range == entry.range)
}
pub fn format_qualified_entry(entry: &OutlineEntry, symbol: Option<&Symbol>) -> String {
let Some(symbol) = symbol else {
return format_entry_with_sig(entry);
};
let qualified_name = qualified_symbol_name(symbol);
if qualified_name == symbol.name {
return format_entry_with_sig(entry);
}
let mut display = entry.clone();
display.name = qualified_name.clone();
let signature = entry.signature.as_deref().unwrap_or(entry.name.as_str());
display.signature = Some(qualified_signature(
&symbol.name,
&qualified_name,
signature,
));
format_entry_with_sig(&display)
}
fn qualified_signature(name: &str, qualified_name: &str, signature: &str) -> String {
if signature == name {
return qualified_name.to_string();
}
if let Some(rest) = signature.strip_prefix(name) {
return format!("{qualified_name}{rest}");
}
format!("{qualified_name} — {signature}")
}
pub fn render_symbol_within_budget(
target: &Symbol,
lines: &[String],
lang: Option<LangId>,
outline: Option<&ContainerOutline>,
max_lines: usize,
) -> BudgetedSymbolRender {
if should_return_member_menu(target, lang, outline) {
let outline = outline.expect("member menu requires an outline");
return BudgetedSymbolRender {
content: render_container_member_menu(target, outline),
status: BudgetedSymbolRenderStatus::Menu,
};
}
let start = (target.range.start_line as usize).min(lines.len());
let end = ((target.range.end_line as usize) + 1).min(lines.len());
if start >= end {
return BudgetedSymbolRender {
content: String::new(),
status: BudgetedSymbolRenderStatus::Complete,
};
}
let render_start = doc_comment_start(lines, start).min(end);
let full_len = end.saturating_sub(render_start);
if full_len <= max_lines {
return BudgetedSymbolRender {
content: lines[render_start..end].join("\n"),
status: BudgetedSymbolRenderStatus::Complete,
};
}
let shown = max_lines.min(full_len);
let remaining = full_len - shown;
let mut content = if shown == 0 {
String::new()
} else {
lines[render_start..render_start + shown].join("\n")
};
if !content.is_empty() {
content.push('\n');
}
content.push_str(&format!(
"… +{remaining} more lines — zoom {} for the full body",
target.name
));
BudgetedSymbolRender {
content,
status: BudgetedSymbolRenderStatus::Truncated,
}
}
pub fn doc_comment_start(lines: &[String], start: usize) -> usize {
let mut s = start;
while s > 0 {
let prev = lines[s - 1].trim_start();
let is_doc_or_attr = prev.starts_with("//")
|| prev.starts_with("/*")
|| prev.starts_with('*')
|| is_hash_doc_or_attr(prev)
|| prev.starts_with("--")
|| prev.starts_with('@');
if !is_doc_or_attr {
break;
}
s -= 1;
}
s
}
fn is_hash_doc_or_attr(line: &str) -> bool {
if line.starts_with("#[") || line.starts_with("#![") {
return true;
}
let Some(rest) = line.strip_prefix('#') else {
return false;
};
let Some(first) = rest.chars().next() else {
return true;
};
first.is_whitespace() && !starts_with_c_preprocessor_directive(rest.trim_start())
}
fn starts_with_c_preprocessor_directive(rest: &str) -> bool {
let directive = rest
.split(|ch: char| !ch.is_ascii_alphabetic())
.next()
.unwrap_or_default();
matches!(
directive,
"define"
| "elif"
| "else"
| "endif"
| "error"
| "if"
| "ifdef"
| "ifndef"
| "include"
| "line"
| "pragma"
| "region"
| "undef"
| "using"
| "warning"
)
}