aft/commands/
symbol_render.rs1use 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 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
258pub 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}