Skip to main content

aptu_coder_core/formatter/
emit.rs

1// SPDX-FileCopyrightText: 2026 aptu-coder contributors
2// SPDX-License-Identifier: Apache-2.0
3//! Low-level string formatting helpers used across formatter sub-modules.
4
5use 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
14/// Check if a function falls within a class's line range (method detection).
15pub(crate) fn is_method_of_class(func: &FunctionInfo, class: &ClassInfo) -> bool {
16    func.line >= class.line && func.end_line <= class.end_line
17}
18
19/// Collect methods for each class, preferring ClassInfo.methods when populated (Rust case),
20/// otherwise falling back to line-range detection.
21pub(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
41/// Format a list of function signatures wrapped at 100 characters with bullet annotation.
42pub(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
78/// Build a bracket string for file info.
79pub(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
101/// Strip a base path from a Path, returning a relative path or the original on failure.
102pub(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
115/// Extract the center line from a snippet window and truncate at a char boundary.
116pub(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
128/// Count (writes, reads) in a def-use site slice.
129pub(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
137/// Render a WRITES or READS group of def-use sites.
138pub(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
171/// Format a single file entry line for paginated directory listing.
172pub(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
191/// Aggregate directory statistics from analyzed files.
192pub(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
199/// Render a top-files section string for directories with many files.
200pub(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
262/// Format the classes section (C:) for a file.
263pub(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
324/// Format the imports section (I:) for a file.
325pub(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}