aptu_coder_core/formatter/
emit.rs1use crate::types::{ClassInfo, DefUseKind, DefUseSite, FileInfo, FunctionInfo, ImportInfo};
6use std::collections::HashMap;
7use std::fmt::Write;
8use std::path::Path;
9
10const MULTILINE_THRESHOLD: usize = 10;
11const SNIPPET_MAX_LEN: usize = 80;
12const SNIPPET_TRUNCATION_POINT: usize = 77;
13
14pub(crate) fn is_method_of_class(func: &FunctionInfo, class: &ClassInfo) -> bool {
16 func.line >= class.line && func.end_line <= class.end_line
17}
18
19pub(crate) fn collect_class_methods<'a>(
22 classes: &'a [ClassInfo],
23 functions: &'a [FunctionInfo],
24) -> HashMap<String, Vec<&'a FunctionInfo>> {
25 let mut methods_by_class: HashMap<String, Vec<&FunctionInfo>> = HashMap::new();
26 for class in classes {
27 if !class.methods.is_empty() {
28 methods_by_class.insert(class.name.clone(), class.methods.iter().collect());
29 continue;
30 }
31 let mut methods: Vec<&FunctionInfo> = functions
32 .iter()
33 .filter(|func| is_method_of_class(func, class))
34 .collect();
35 methods.sort_by_key(|f| f.line);
36 methods_by_class.insert(class.name.clone(), methods);
37 }
38 methods_by_class
39}
40
41pub(crate) fn format_function_list_wrapped<'a>(
43 functions: impl Iterator<Item = &'a crate::types::FunctionInfo>,
44 call_frequency: &std::collections::HashMap<String, usize>,
45) -> String {
46 let mut output = String::new();
47 let mut line = String::from(" ");
48 for (i, func) in functions.enumerate() {
49 let mut call_marker = func.compact_signature();
50
51 if let Some(&count) = call_frequency.get(&func.name)
52 && count > 3
53 {
54 let _ = write!(call_marker, "\u{2022}{count}");
55 }
56
57 if i == 0 {
58 line.push_str(&call_marker);
59 } else if line.len() + call_marker.len() + 2 > 100 {
60 output.push_str(&line);
61 output.push('\n');
62 let mut new_line = String::with_capacity(2 + call_marker.len());
63 new_line.push_str(" ");
64 new_line.push_str(&call_marker);
65 line = new_line;
66 } else {
67 line.push_str(", ");
68 line.push_str(&call_marker);
69 }
70 }
71 if !line.trim().is_empty() {
72 output.push_str(&line);
73 output.push('\n');
74 }
75 output
76}
77
78pub(crate) fn format_file_info_parts(
80 line_count: usize,
81 fn_count: usize,
82 cls_count: usize,
83) -> Option<String> {
84 let mut parts = Vec::new();
85 if line_count > 0 {
86 parts.push(format!("{line_count}L"));
87 }
88 if fn_count > 0 {
89 parts.push(format!("{fn_count}F"));
90 }
91 if cls_count > 0 {
92 parts.push(format!("{cls_count}C"));
93 }
94 if parts.is_empty() {
95 None
96 } else {
97 Some(format!("[{}]", parts.join(", ")))
98 }
99}
100
101pub(crate) fn strip_base_path(path: &Path, base_path: Option<&Path>) -> String {
103 match base_path {
104 Some(base) => {
105 if let Ok(rel_path) = path.strip_prefix(base) {
106 rel_path.display().to_string()
107 } else {
108 path.display().to_string()
109 }
110 }
111 None => path.display().to_string(),
112 }
113}
114
115pub(crate) fn snippet_one_line(snippet: &str) -> String {
117 let lines: Vec<&str> = snippet.split('\n').collect();
118 let center = if lines.len() >= 2 { lines[1] } else { lines[0] };
119 let trimmed = center.trim();
120 if trimmed.len() > SNIPPET_MAX_LEN {
121 let truncate_at = trimmed.floor_char_boundary(SNIPPET_TRUNCATION_POINT);
122 format!("{}...", &trimmed[..truncate_at])
123 } else {
124 trimmed.to_string()
125 }
126}
127
128pub(crate) fn def_use_write_read_counts(sites: &[DefUseSite]) -> (usize, usize) {
130 let w = sites
131 .iter()
132 .filter(|s| matches!(s.kind, DefUseKind::Write | DefUseKind::WriteRead))
133 .count();
134 (w, sites.len() - w)
135}
136
137pub(crate) fn render_def_use_group(
139 output: &mut String,
140 sites: &[DefUseSite],
141 heading: &str,
142 pred: impl Fn(&DefUseSite) -> bool,
143 base_path: Option<&Path>,
144) {
145 let filtered: Vec<_> = sites.iter().filter(|s| pred(s)).collect();
146 if filtered.is_empty() {
147 return;
148 }
149 let _ = writeln!(output, " {heading}");
150 for site in filtered {
151 let file_display = strip_base_path(Path::new(&site.file), base_path);
152 let scope_str = site
153 .enclosing_scope
154 .as_ref()
155 .map(|s| format!("{}()", s))
156 .unwrap_or_default();
157 let snippet = snippet_one_line(&site.snippet);
158 let wr_label = if site.kind == DefUseKind::WriteRead {
159 " [write_read]"
160 } else {
161 ""
162 };
163 let _ = writeln!(
164 output,
165 " {file_display}:{} {scope_str} {snippet}{wr_label}",
166 site.line
167 );
168 }
169}
170
171pub(crate) fn format_file_entry(file: &FileInfo, base_path: Option<&Path>) -> String {
173 let mut parts = Vec::new();
174 if file.line_count > 0 {
175 parts.push(format!("{}L", file.line_count));
176 }
177 if file.function_count > 0 {
178 parts.push(format!("{}F", file.function_count));
179 }
180 if file.class_count > 0 {
181 parts.push(format!("{}C", file.class_count));
182 }
183 let display_path = strip_base_path(Path::new(&file.path), base_path);
184 if parts.is_empty() {
185 format!("{display_path}\n")
186 } else {
187 format!("{display_path} [{}]\n", parts.join(", "))
188 }
189}
190
191pub(crate) fn aggregate_dir_stats(files_in_dir: &[&FileInfo]) -> (usize, usize, usize) {
193 let dir_loc: usize = files_in_dir.iter().map(|f| f.line_count).sum();
194 let dir_functions: usize = files_in_dir.iter().map(|f| f.function_count).sum();
195 let dir_classes: usize = files_in_dir.iter().map(|f| f.class_count).sum();
196 (dir_loc, dir_functions, dir_classes)
197}
198
199pub(crate) fn render_top_files_section(
201 files_in_dir: &[&FileInfo],
202 dir_path: &Path,
203 has_classes: bool,
204) -> String {
205 let top_files_sorted: Vec<&FileInfo> = if has_classes {
206 let mut sorted = files_in_dir.to_vec();
207 sorted.sort_unstable_by(|a, b| {
208 b.class_count
209 .cmp(&a.class_count)
210 .then(b.function_count.cmp(&a.function_count))
211 .then(a.path.cmp(&b.path))
212 });
213 sorted
214 } else {
215 let mut sorted = files_in_dir.to_vec();
216 sorted.sort_unstable_by(|a, b| {
217 b.function_count
218 .cmp(&a.function_count)
219 .then(a.path.cmp(&b.path))
220 });
221 sorted
222 };
223
224 let top_n: Vec<String> = top_files_sorted
225 .iter()
226 .take(3)
227 .filter(|f| {
228 if has_classes {
229 f.class_count > 0
230 } else {
231 f.function_count > 0
232 }
233 })
234 .map(|f| {
235 let rel = Path::new(&f.path).strip_prefix(dir_path).map_or_else(
236 |_| {
237 Path::new(&f.path)
238 .file_name()
239 .and_then(|n| n.to_str())
240 .map_or_else(|| "?".to_owned(), std::borrow::ToOwned::to_owned)
241 },
242 |p| p.to_string_lossy().into_owned(),
243 );
244 let count = if has_classes {
245 f.class_count
246 } else {
247 f.function_count
248 };
249 let suffix = if has_classes { 'C' } else { 'F' };
250 format!("{rel}({count}{suffix})")
251 })
252 .collect();
253
254 if top_n.is_empty() {
255 String::new()
256 } else {
257 let joined = top_n.join(", ");
258 format!(" top: {joined}")
259 }
260}
261
262pub(crate) fn format_classes_section(classes: &[ClassInfo], functions: &[FunctionInfo]) -> String {
264 let mut output = String::new();
265 if classes.is_empty() {
266 return output;
267 }
268 output.push_str("C:\n");
269
270 let methods_by_class = collect_class_methods(classes, functions);
271 let has_methods = methods_by_class.values().any(|m| !m.is_empty());
272
273 if classes.len() <= MULTILINE_THRESHOLD && !has_methods {
274 let class_strs: Vec<String> = classes
275 .iter()
276 .map(|class| {
277 if class.inherits.is_empty() {
278 format!("{}:{}-{}", class.name, class.line, class.end_line)
279 } else {
280 format!(
281 "{}:{}-{} ({})",
282 class.name,
283 class.line,
284 class.end_line,
285 class.inherits.join(", ")
286 )
287 }
288 })
289 .collect();
290 output.push_str(" ");
291 output.push_str(&class_strs.join("; "));
292 output.push('\n');
293 } else {
294 for class in classes {
295 if class.inherits.is_empty() {
296 let _ = writeln!(output, " {}:{}-{}", class.name, class.line, class.end_line);
297 } else {
298 let _ = writeln!(
299 output,
300 " {}:{}-{} ({})",
301 class.name,
302 class.line,
303 class.end_line,
304 class.inherits.join(", ")
305 );
306 }
307
308 if let Some(methods) = methods_by_class.get(&class.name)
309 && !methods.is_empty()
310 {
311 for (i, method) in methods.iter().take(10).enumerate() {
312 let _ = writeln!(output, " {}:{}", method.name, method.line);
313 if i + 1 == 10 && methods.len() > 10 {
314 let _ = writeln!(output, " ... ({} more)", methods.len() - 10);
315 break;
316 }
317 }
318 }
319 }
320 }
321 output
322}
323
324pub(crate) fn format_imports_section(imports: &[ImportInfo]) -> String {
326 let mut output = String::new();
327 if imports.is_empty() {
328 return output;
329 }
330 output.push_str("I:\n");
331 let mut module_map: HashMap<String, usize> = HashMap::new();
332 for import in imports {
333 module_map
334 .entry(import.module.clone())
335 .and_modify(|count| *count += 1)
336 .or_insert(1);
337 }
338 let mut modules: Vec<_> = module_map.keys().cloned().collect();
339 modules.sort();
340 let formatted_modules: Vec<String> = modules
341 .iter()
342 .map(|module| format!("{}({})", module, module_map[module]))
343 .collect();
344 if formatted_modules.len() <= MULTILINE_THRESHOLD {
345 output.push_str(" ");
346 output.push_str(&formatted_modules.join("; "));
347 output.push('\n');
348 } else {
349 for module_str in formatted_modules {
350 output.push_str(" ");
351 output.push_str(&module_str);
352 output.push('\n');
353 }
354 }
355 output
356}