Skip to main content

aft/commands/
symbol_render.rs

1use std::path::Path;
2
3use crate::commands::outline::{
4    build_outline_tree, format_entry_with_sig, symbol_to_entry, OutlineEntry,
5};
6use crate::context::AppContext;
7use crate::parser::LangId;
8use crate::symbols::{Range, Symbol, SymbolKind};
9
10pub const LARGE_CONTAINER_MENU_LINE_THRESHOLD: usize = 150;
11
12pub struct ContainerOutline {
13    entry: OutlineEntry,
14    symbols: Vec<Symbol>,
15}
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum BudgetedSymbolRenderStatus {
19    Complete,
20    Truncated,
21    Menu,
22}
23
24pub struct BudgetedSymbolRender {
25    pub content: String,
26    pub status: BudgetedSymbolRenderStatus,
27}
28
29pub fn symbol_kind_string(kind: &SymbolKind) -> String {
30    serde_json::to_value(kind)
31        .ok()
32        .and_then(|value| value.as_str().map(String::from))
33        .unwrap_or_else(|| format!("{kind:?}").to_lowercase())
34}
35
36pub fn qualified_symbol_name(symbol: &Symbol) -> String {
37    let mut parts = symbol
38        .scope_chain
39        .iter()
40        .filter(|part| !part.is_empty())
41        .cloned()
42        .collect::<Vec<_>>();
43    parts.push(symbol.name.clone());
44    parts.join(".")
45}
46
47pub fn might_have_container_members(symbol: &Symbol) -> bool {
48    matches!(
49        &symbol.kind,
50        SymbolKind::Class
51            | SymbolKind::Struct
52            | SymbolKind::Interface
53            | SymbolKind::Enum
54            | SymbolKind::Variable
55            | SymbolKind::TypeAlias
56    )
57}
58
59fn is_container_kind(kind: &SymbolKind) -> bool {
60    matches!(
61        kind,
62        SymbolKind::Class | SymbolKind::Struct | SymbolKind::Interface | SymbolKind::Enum
63    )
64}
65
66pub fn build_container_outline(
67    ctx: &AppContext,
68    resolved_file_path: &Path,
69    target: &Symbol,
70) -> Result<ContainerOutline, crate::error::AftError> {
71    let symbols = ctx.provider().list_symbols(resolved_file_path)?;
72    let entries = build_outline_tree(&symbols);
73    let entry = find_outline_entry(&entries, target)
74        .cloned()
75        .unwrap_or_else(|| symbol_to_entry(target));
76    Ok(ContainerOutline { entry, symbols })
77}
78
79fn find_outline_entry<'a>(
80    entries: &'a [OutlineEntry],
81    target: &Symbol,
82) -> Option<&'a OutlineEntry> {
83    for entry in entries {
84        if entry.name == target.name && entry.range == target.range {
85            return Some(entry);
86        }
87        if let Some(found) = find_outline_entry(&entry.members, target) {
88            return Some(found);
89        }
90    }
91    None
92}
93
94pub fn should_return_member_menu(
95    target: &Symbol,
96    lang: Option<LangId>,
97    outline: Option<&ContainerOutline>,
98) -> bool {
99    let Some(outline) = outline else {
100        return false;
101    };
102    let is_container = is_container_kind(&target.kind) || !outline.entry.members.is_empty();
103    if !is_container {
104        return false;
105    }
106
107    container_rendered_line_count(target, lang, &outline.entry)
108        > LARGE_CONTAINER_MENU_LINE_THRESHOLD
109}
110
111fn range_line_count(range: &Range) -> usize {
112    range
113        .end_line
114        .saturating_sub(range.start_line)
115        .saturating_add(1) as usize
116}
117
118fn range_contains(outer: &Range, inner: &Range) -> bool {
119    (outer.start_line, outer.start_col) <= (inner.start_line, inner.start_col)
120        && (outer.end_line, outer.end_col) >= (inner.end_line, inner.end_col)
121}
122
123fn container_rendered_line_count(
124    target: &Symbol,
125    lang: Option<LangId>,
126    entry: &OutlineEntry,
127) -> usize {
128    let mut line_count = range_line_count(&target.range);
129
130    // Rust impl blocks are associated with the type in the outline symbol model,
131    // but their method ranges sit outside the struct/enum/trait declaration.
132    // Count those associated method spans so behavior-heavy Rust types get a
133    // drill-down menu without introducing a separate `impl` zoom target.
134    if lang == Some(LangId::Rust) {
135        for member in &entry.members {
136            if !range_contains(&target.range, &member.range) {
137                line_count = line_count.saturating_add(range_line_count(&member.range));
138            }
139        }
140    }
141
142    line_count
143}
144
145pub fn render_container_member_menu(target: &Symbol, outline: &ContainerOutline) -> String {
146    let kind = symbol_kind_string(&target.kind);
147    let qualified_name = qualified_symbol_name(target);
148    let member_count = outline.entry.members.len();
149    let mut lines = vec![format!(
150        "{kind} {qualified_name} ({member_count} members) — member-signature menu; zoom a member for its body"
151    )];
152
153    lines.push(format_qualified_entry(&outline.entry, Some(target)));
154    if outline.entry.members.is_empty() {
155        lines.push("  (no direct members found)".to_string());
156    } else {
157        for member in &outline.entry.members {
158            let symbol = find_symbol_for_entry(&outline.symbols, member);
159            lines.push(format!("  .{}", format_qualified_entry(member, symbol)));
160        }
161    }
162
163    lines.join("\n")
164}
165
166fn find_symbol_for_entry<'a>(symbols: &'a [Symbol], entry: &OutlineEntry) -> Option<&'a Symbol> {
167    symbols
168        .iter()
169        .find(|symbol| symbol.name == entry.name && symbol.range == entry.range)
170}
171
172pub fn format_qualified_entry(entry: &OutlineEntry, symbol: Option<&Symbol>) -> String {
173    let Some(symbol) = symbol else {
174        return format_entry_with_sig(entry);
175    };
176    let qualified_name = qualified_symbol_name(symbol);
177    if qualified_name == symbol.name {
178        return format_entry_with_sig(entry);
179    }
180
181    let mut display = entry.clone();
182    display.name = qualified_name.clone();
183    let signature = entry.signature.as_deref().unwrap_or(entry.name.as_str());
184    display.signature = Some(qualified_signature(
185        &symbol.name,
186        &qualified_name,
187        signature,
188    ));
189    format_entry_with_sig(&display)
190}
191
192fn qualified_signature(name: &str, qualified_name: &str, signature: &str) -> String {
193    if signature == name {
194        return qualified_name.to_string();
195    }
196
197    if let Some(rest) = signature.strip_prefix(name) {
198        return format!("{qualified_name}{rest}");
199    }
200
201    format!("{qualified_name} — {signature}")
202}
203
204pub fn render_symbol_within_budget(
205    target: &Symbol,
206    lines: &[String],
207    lang: Option<LangId>,
208    outline: Option<&ContainerOutline>,
209    max_lines: usize,
210) -> BudgetedSymbolRender {
211    if should_return_member_menu(target, lang, outline) {
212        let outline = outline.expect("member menu requires an outline");
213        return BudgetedSymbolRender {
214            content: render_container_member_menu(target, outline),
215            status: BudgetedSymbolRenderStatus::Menu,
216        };
217    }
218
219    let start = (target.range.start_line as usize).min(lines.len());
220    let end = ((target.range.end_line as usize) + 1).min(lines.len());
221    if start >= end {
222        return BudgetedSymbolRender {
223            content: String::new(),
224            status: BudgetedSymbolRenderStatus::Complete,
225        };
226    }
227
228    let render_start = doc_comment_start(lines, start).min(end);
229    let full_len = end.saturating_sub(render_start);
230    if full_len <= max_lines {
231        return BudgetedSymbolRender {
232            content: lines[render_start..end].join("\n"),
233            status: BudgetedSymbolRenderStatus::Complete,
234        };
235    }
236
237    let shown = max_lines.min(full_len);
238    let remaining = full_len - shown;
239    let mut content = if shown == 0 {
240        String::new()
241    } else {
242        lines[render_start..render_start + shown].join("\n")
243    };
244    if !content.is_empty() {
245        content.push('\n');
246    }
247    content.push_str(&format!(
248        "… +{remaining} more lines — zoom {} for the full body",
249        target.name
250    ));
251
252    BudgetedSymbolRender {
253        content,
254        status: BudgetedSymbolRenderStatus::Truncated,
255    }
256}
257
258/// Walk `start` (0-based index of the symbol's first body line) backwards over a
259/// contiguous block of leading doc-comment / attribute / decorator lines, so the
260/// rank-0 preview includes the symbol's doc the way aft_zoom does. Stops at the
261/// first blank line or non-comment/non-decorator line — i.e. the previous
262/// symbol's code — so it never bleeds a neighbor into the preview. Heuristic by
263/// line prefix to stay language-agnostic: `//` `///` `//!` (Rust/TS/JS/Go/…),
264/// `/*` `*` `*/` (block / JSDoc), Rust `#[attr]`/`#![...]`, `# ` comments
265/// (Python/Ruby/Bash), `--` (Lua/SQL), and `@` (TS/Java/Python decorators).
266pub fn doc_comment_start(lines: &[String], start: usize) -> usize {
267    let mut s = start;
268    while s > 0 {
269        let prev = lines[s - 1].trim_start();
270        let is_doc_or_attr = prev.starts_with("//")
271            || prev.starts_with("/*")
272            || prev.starts_with('*')
273            || is_hash_doc_or_attr(prev)
274            || prev.starts_with("--")
275            || prev.starts_with('@');
276        if !is_doc_or_attr {
277            break;
278        }
279        s -= 1;
280    }
281    s
282}
283
284fn is_hash_doc_or_attr(line: &str) -> bool {
285    if line.starts_with("#[") || line.starts_with("#![") {
286        return true;
287    }
288
289    let Some(rest) = line.strip_prefix('#') else {
290        return false;
291    };
292    let Some(first) = rest.chars().next() else {
293        return true;
294    };
295    first.is_whitespace() && !starts_with_c_preprocessor_directive(rest.trim_start())
296}
297
298fn starts_with_c_preprocessor_directive(rest: &str) -> bool {
299    let directive = rest
300        .split(|ch: char| !ch.is_ascii_alphabetic())
301        .next()
302        .unwrap_or_default();
303    matches!(
304        directive,
305        "define"
306            | "elif"
307            | "else"
308            | "endif"
309            | "error"
310            | "if"
311            | "ifdef"
312            | "ifndef"
313            | "include"
314            | "line"
315            | "pragma"
316            | "region"
317            | "undef"
318            | "using"
319            | "warning"
320    )
321}