paline 0.4.2

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

use crate::analyzer::ProjectStats;
use crate::lang::Languages;

use crate::logger::{Level, Log, Verbosity};

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

/// 一种语言及其统计到的行数。
struct LangLines {
    lang: Languages,
    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])
    }
}

/// 绘制横向条形图的单个条形串。
///
/// 将总行数按比例分配给各语言,以 `━` 字符个数表示占比;
/// 采用最大余数法分配剩余字符,保证每个语言至少占 1 格。
/// 无语言、终端宽度不足或总行数为 0 时返回空字符串。
fn render_bar(langs: &[LangLines], half_width: usize, total_lines: u32) -> String {
    let langs: Vec<&LangLines> = langs.iter().filter(|ll| ll.lines > 0).collect();
    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(&ll.lang.to_rgb().fg_str(&"".repeat(units[i])));
    }
    bar
}

pub fn render(mode: Mode) -> 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 mut langs: [LangLines; Languages::COUNT] = Languages::ALL.map(|lang| LangLines {
                lang,
                lines: stats.language_line_map[lang.idx()],
            });
            langs.sort_unstable_by_key(|ll| std::cmp::Reverse(ll.lines)); //排序,确保每次运行的图例保持稳定

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

                for ll in &langs {
                    if ll.lines == 0 {
                        continue;
                    }
                    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)",
                        ll.lang.to_rgb().fg_str(""),
                        ll.lang.to_str(),
                        pct_str,
                        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 } => {
            let mut deltas: Vec<(Languages, i64)> = Vec::new();
            for lang in Languages::ALL {
                let delta = target.language_line_map[lang.idx()] as i64
                    - base.language_line_map[lang.idx()] as i64;
                if delta != 0 {
                    deltas.push((lang, delta));
                }
            }
            deltas.sort_unstable_by_key(|(_, d)| d.abs());
            deltas.reverse();

            println!("Line Diff:");
            if !deltas.is_empty() {
                let lang_lines: Vec<LangLines> = deltas
                    .iter()
                    .map(|(lang, d)| LangLines {
                        lang: *lang,
                        lines: d.unsigned_abs() as u32,
                    })
                    .collect();
                let total_abs: u32 = lang_lines.iter().map(|ll| ll.lines).sum();
                println!("{}", render_bar(&lang_lines, half_width, total_abs));

                for (lang, d) in &deltas {
                    let sign = if *d >= 0 { "+" } else { "-" };
                    println!(
                        "{} {} {}{} Lines",
                        lang.to_rgb().fg_str(""),
                        lang.to_str(),
                        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 { "-" }
}