paline 0.5.0

一个用 Rust 编写的代码行数统计命令行工具。递归扫描目录,按编程语言统计行数,并以彩色横向条形图在终端中可视化展示。
use owo_colors::OwoColorize;
use terminal_size::{terminal_size, Width};

use crate::analyzer::ProjectStats;
use crate::config::LanguageIndex;
use crate::logger::{Level, Log, Verbosity};

pub enum Mode {
    Stats {
        stats: ProjectStats,
    },
    Diff {
        base: ProjectStats,
        target: ProjectStats,
    },
}

/// 一种语言及其统计到的行数(用于渲染)
struct LangLines {
    name: String,
    color: owo_colors::Rgb,
    lines: u32,
}

/// 条形图按比例分配时的余数记录,用于最大余数法补齐。
struct Remainder {
    frac: f64,
    index: usize,
}

/// 获取终端宽度(字符数)。非终端环境或无宽度时返回 [`None`]。
fn terminal_width() -> Option<u16> {
    terminal_size().map(|(Width(w), _)| w)
}

/// 格式化数字,每三个0加一个逗号,方便阅读
fn format_thousands(n: u32) -> String {
    if n == 0 {
        return "0".to_string();
    }
    let mut n = n;
    let mut out = String::new();
    let mut count: u32 = 0;
    while n > 0 {
        if count > 0 && count % 3 == 0 {
            out.push(',');
        }
        out.push(char::from_digit(n % 10, 10).unwrap());
        n /= 10;
        count += 1;
    }
    out.chars().rev().collect()
}

/// 格式化文件大小
fn format_size(bytes: u64) -> String {
    const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
    let mut value = bytes as f64;
    let mut unit = 0;

    // 计算能除多少1000
    while value >= 1000.0 && unit < UNITS.len() - 1 {
        value /= 1000.0;
        unit += 1;
    }
    if unit == 0 {
        format!("{} B", bytes) // Byte不需要取小数,因为已经是最小单位
    } else {
        format!("{:.1} {}", value, UNITS[unit])
    }
}

/// 从 ProjectStats 和 LanguageIndex 构建有序的 LangLines 列表(按行数降序,跳过0行)
fn build_lang_lines(stats: &ProjectStats, index: &LanguageIndex) -> Vec<LangLines> {
    let mut result = Vec::new();
    for (id, &lines) in &stats.language_line_map {
        if lines == 0 {
            continue;
        }
        if let Some(entry) = index.get(id) {
            result.push(LangLines {
                name: entry.name.clone(),
                color: entry.to_rgb(),
                lines,
            });
        }
    }
    result.sort_by(|a, b| b.lines.cmp(&a.lines));
    result
}

/// 绘制横向条形图的单个条形串。
///
/// 将总行数按比例分配给各语言,以 `━` 字符个数表示占比;
/// 采用最大余数法分配剩余字符,保证每个语言至少占 1 格。
/// 无语言、终端宽度不足或总行数为 0 时返回空字符串。
fn render_bar(langs: &[LangLines], half_width: usize, total_lines: u32) -> String {
    let n = langs.len();
    if n == 0 || half_width == 0 || total_lines == 0 {
        return String::new();
    }

    let total_units = half_width.max(n);
    let mut units = Vec::with_capacity(n);
    let mut required = 0usize;
    let mut remainders: Vec<Remainder> = Vec::with_capacity(n);
    for (i, ll) in langs.iter().enumerate() {
        let quota = ll.lines as f64 / total_lines as f64 * total_units as f64;
        let mut u = quota.floor() as usize;
        if u == 0 {
            u = 1;
        }
        units.push(u);
        required += u;
        remainders.push(Remainder {
            frac: quota - quota.floor(),
            index: i,
        });
    }

    let total = total_units.max(required);
    let mut remaining = total - required;
    remainders.sort_by(|a, b| b.frac.total_cmp(&a.frac));
    while remaining > 0 {
        for r in &remainders {
            if remaining == 0 {
                break;
            }
            units[r.index] += 1;
            remaining -= 1;
        }
    }

    let mut bar = String::new();
    for (i, ll) in langs.iter().enumerate() {
        bar.push_str(&format!("{}", &"".repeat(units[i]).color(ll.color)));
    }
    bar
}

pub fn render(mode: Mode, index: &LanguageIndex) -> Result<(), Log> {
    let Some(terminal_w) = terminal_width() else {
        return Err(Log::new(
            Level::Error,
            "Unable to get terminal size, please make sure to run this program in a terminal"
                .to_string(),
            Some(Verbosity::Normal),
        ));
    };
    let half_width = (terminal_w as usize) / 2;

    match mode {
        Mode::Stats { stats } => {
            let lang_lines = build_lang_lines(&stats, index);

            if stats.total_lines > 0 {
                println!("Language Line Distribution:");
                println!("{}", render_bar(&lang_lines, half_width, stats.total_lines));

                for ll in &lang_lines {
                    let share = ll.lines as f64 / stats.total_lines as f64;
                    let pct = share * 100.0;
                    let pct_str = if pct < 0.1 {
                        "< 0.1%".to_string()
                    } else {
                        format!("{:.1}%", pct)
                    };
                    println!(
                        "{} {} {} ({} Lines)",
                        "".color(ll.color),
                        ll.name,
                        pct_str,
                        format_thousands(ll.lines)
                    );
                }
                println!();
            }
            println!(
                "Total: {} lines  |  Files: {}  |  Size: {}",
                format_thousands(stats.total_lines),
                stats.total_files,
                format_size(stats.total_bytes)
            );

            Ok(())
        }
        Mode::Diff { base, target } => {
            // 收集所有语言 ID,计算差值
            let mut deltas: Vec<(String, i64)> = Vec::new();
            // 用 index.all_entries() 遍历所有语言,保证覆盖所有配置中的语言
            for entry in index.all_entries() {
                let id = &entry.id;
                let base_lines = base.language_line_map.get(id).copied().unwrap_or(0) as i64;
                let target_lines = target.language_line_map.get(id).copied().unwrap_or(0) as i64;
                let delta = target_lines - base_lines;
                if delta != 0 {
                    deltas.push((id.clone(), delta));
                }
            }
            deltas.sort_unstable_by_key(|(_, d)| d.abs());
            deltas.reverse();

            println!("Line Diff:");
            if !deltas.is_empty() {
                // 构造用于绘图的 LangLines(绝对行数)
                let mut lang_lines: Vec<LangLines> = deltas
                    .iter()
                    .filter_map(|(id, d)| {
                        index.get(id).map(|entry| LangLines {
                            name: entry.name.clone(),
                            color: entry.to_rgb(),
                            lines: d.unsigned_abs() as u32,
                        })
                    })
                    .collect();
                // 按绝对行数降序排序(与 deltas 排序一致)
                lang_lines.sort_by(|a, b| b.lines.cmp(&a.lines));
                let total_abs: u32 = lang_lines.iter().map(|ll| ll.lines).sum();
                println!("{}", render_bar(&lang_lines, half_width, total_abs));

                // 打印详细信息,使用 deltas 中的顺序
                for (id, d) in &deltas {
                    if let Some(entry) = index.get(id) {
                        let sign = if *d >= 0 { "+" } else { "-" };
                        println!(
                            "{} {} {}{} Lines",
                            "".color(entry.to_rgb()),
                            entry.name,
                            sign,
                            format_thousands(d.unsigned_abs() as u32)
                        );
                    }
                }
                println!();
            }

            let line_delta = target.total_lines as i64 - base.total_lines as i64;
            let file_delta = target.total_files as i64 - base.total_files as i64;
            let byte_delta = target.total_bytes as i64 - base.total_bytes as i64;
            println!(
                "Total: {}{} Lines  |  Files: {}{}  |  Size: {}{}",
                signed_prefix(line_delta),
                format_thousands(line_delta.unsigned_abs() as u32),
                signed_prefix(file_delta),
                file_delta.unsigned_abs(),
                signed_prefix(byte_delta),
                format_size(byte_delta.unsigned_abs())
            );

            Ok(())
        }
    }
}

/// 返回数字的符号前缀,非负数用 `+`,负数用 `-`。
fn signed_prefix(n: i64) -> &'static str {
    if n >= 0 {
        "+"
    } else {
        "-"
    }
}