feat: add lines-of-code counting and summary

Introduces a built-in lines-of-code (LOC) counter to `eza` that supports
over 50 programming languages.

- Adds the `--code` flag to provide a project-wide summary of code,
  comments, and blanks by language.
- Adds the `--loc` flag to include lines-of-code and language columns in
  the long view.
- Provides support for multiple display modes (`lines`, `percent`,
  `both`) for both flags.
- Includes comment-aware counting, respecting language-specific comment
  syntax while ignoring string literals.
- Updates completions for fish, nushell, powershell, and zsh to include
  the new flags.
This commit is contained in:
Christina Sørensen
2026-07-09 10:36:34 +02:00
committed by Christina Sørensen
parent 247fb8ed07
commit 4a7bfa58fc
35 changed files with 1317 additions and 9 deletions
+2
View File
@@ -59,6 +59,7 @@ complete -c eza -s a -l all -d "Show hidden and 'dot' files. Use this twice to a
complete -c eza -s A -l almost-all -d "Equivalent to --all; included for compatibility with `ls -A`"
complete -c eza -s d -l treat-dirs-as-files -d "List directories like regular files"
complete -c eza -s L -l level -d "Limit the depth of recursion" -x -a "1 2 3 4 5 6 7 8 9"
complete -c eza -l code -d "Summarise lines of code by language" -x -a "lines percent both"
complete -c eza -s w -l width -d "Limits column output of grid, 0 implies auto-width"
complete -c eza -s r -l reverse -d "Reverse the sort order"
complete -c eza -s s -l sort -d "Which field to sort by" -x -a "
@@ -98,6 +99,7 @@ complete -c eza -s g -l group -d "List each file's group"
complete -c eza -s h -l header -d "Add a header row to each column"
complete -c eza -s H -l links -d "List each file's number of hard links"
complete -c eza -s i -l inode -d "List each file's inode number"
complete -c eza -l loc -d "Add lines-of-code and language columns" -x -a "lines percent both"
complete -c eza -s S -l blocksize -d "List each file's size of allocated file system blocks"
complete -c eza -s t -l time -d "Which timestamp field to list" -x -a "
modified\t'Display modified time'
+2
View File
@@ -40,6 +40,8 @@ export extern "eza" [
--header(-h) # Add a header row to each column
--links(-H) # List each file's number of hard links
--inode(-i) # List each file's inode number
--loc: string # Add lines-of-code and language columns (lines, percent, both)
--code: string # Summarise lines of code by language (lines, percent, both)
--blocksize(-S) # List each file's size of allocated file system blocks
--time(-t) -d # Which timestamp field to list
--modified(-m) # Use the modified timestamp field
+15 -2
View File
@@ -11,6 +11,7 @@ Register-ArgumentCompleter -Native -CommandName 'eza' -ScriptBlock {
$ArrayAbsolute = @('on', 'follow', 'off')
$ArrayTime = @('modified', 'accessed', 'created')
$ArrayTimeStyle = @('default', 'iso', 'long-iso', 'full-iso', 'relative', '+%Y-%m-%d %H:%M', '+%Y.%m.%d %H:$M:$s')
$ArrayCodeMode = @('lines', 'percent', 'both')
$commandElements = $commandAst.CommandElements
$command = @(
@@ -63,7 +64,17 @@ Register-ArgumentCompleter -Native -CommandName 'eza' -ScriptBlock {
break
}
'*;--classify' {
$ArrayWhen |
$ArrayWhen |
ForEach-Object {[System.Management.Automation.CompletionResult]::new($_, $_, "ParameterValue", $_)}
break
}
'*;--loc' {
$ArrayCodeMode |
ForEach-Object {[System.Management.Automation.CompletionResult]::new($_, $_, "ParameterValue", $_)}
break
}
'*;--code' {
$ArrayCodeMode |
ForEach-Object {[System.Management.Automation.CompletionResult]::new($_, $_, "ParameterValue", $_)}
break
}
@@ -100,7 +111,9 @@ Register-ArgumentCompleter -Native -CommandName 'eza' -ScriptBlock {
# [CompletionResult]::new('-H' ,'links' , [CompletionResultType]::ParameterName, 'list each file''s number of hard links')
[CompletionResult]::new('--links' ,'links' , [CompletionResultType]::ParameterName, 'list each file''s number of hard links')
# [CompletionResult]::new('-i' ,'inode' , [CompletionResultType]::ParameterName, 'list each file''s inode number')
[CompletionResult]::new('--inode' ,'inode' , [CompletionResultType]::ParameterName, 'list each file''s inode number')
[CompletionResult]::new('--inode' ,'inode' , [CompletionResultType]::ParameterName, 'list each file''s inode number')
[CompletionResult]::new('--loc' ,'loc' , [CompletionResultType]::ParameterName, 'add lines-of-code and language columns')
[CompletionResult]::new('--code' ,'code' , [CompletionResultType]::ParameterName, 'summarise lines of code by language')
# [CompletionResult]::new('-M' ,'mounts' , [CompletionResultType]::ParameterName, 'show mount details (Linux and Mac only)')
# [CompletionResult]::new('--mounts' ,'mounts' , [CompletionResultType]::ParameterName, 'show mount details (Linux and Mac only)')
# [CompletionResult]::new('-n' ,'numeric' , [CompletionResultType]::ParameterName, 'list numeric user and group IDs')
+2
View File
@@ -50,6 +50,8 @@ __eza() {
{-h,--header}"[Add a header row to each column]" \
{-H,--links}"[List each file's number of hard links]" \
{-i,--inode}"[List each file's inode number]" \
--loc="[Add lines-of-code and language columns]:(mode):(lines percent both)" \
--code="[Summarise lines of code by language]:(mode):(lines percent both)" \
{-m,--modified}"[Use the modified timestamp field]" \
{-n,--numeric}"[List numeric user and group IDs.]" \
{-S,--blocksize}"[List each file's size of allocated file system blocks.]" \
+12
View File
@@ -83,6 +83,11 @@ When used without a value, defaults to ‘`automatic`’.
`-T`, `--tree`
: Recurse into directories as a tree.
`--code[=MODE]`
: Print a lines-of-code summary by language instead of listing files, in the spirit of tools like `tokei` and `cloc`.
: The given paths (or the current directory) are walked recursively, honouring a git repository’s `.gitignore` when one is present, and each recognised language is reported with its file, line, code, comment, and blank counts, plus a bar visualising its share of the code. Valid modes are ‘`lines`’, ‘`percent`’, and ‘`both`’ (the default).
`--follow-symlinks`
: Drill down into symbolic links that point to directories.
@@ -221,6 +226,13 @@ These options are available when running with `--long` (`-l`):
`-i`, `--inode`
: List each file’s inode number.
`--loc[=MODE]`
: Add a language column and a lines-of-code column to the long view.
: Only regular files in a recognised programming language are counted; counting is comment-aware, so the code column excludes comment and blank lines.
: Valid modes are ‘`lines`’ (the count of code lines), ‘`percent`’ (each file’s share of the code in the whole tree), and ‘`both`’ (the default). In `percent` and `both` modes the denominator is the total code across the recursed tree, or the git repository if one is present.
`-m`, `--modified`
: Use the modified timestamp field.
+7
View File
@@ -183,6 +183,13 @@ commands:
? - -i
- --inode
:
? # so only the bare forms are generated here.
- null # --loc and --code take their mode via `=` only (lines/percent/both),
- --loc
: prefix: -l
? - null
- --code
:
? - -m
- --modified
:
+19
View File
@@ -338,6 +338,25 @@ impl<'dir> File<'dir> {
self.filetype().is_some_and(std::fs::FileType::is_file)
}
/// The programming language this file is written in, worked out from its
/// name or extension, if eza recognises it.
pub fn language(&self) -> Option<&'static crate::loc::Language> {
crate::loc::language_for(&self.name, self.ext.as_deref())
}
/// Count this file’s lines of code. Returns `None` for anything that
/// isn’t a readable, regular file in a recognised language — that is,
/// directories, links, unknown extensions, and binaries.
pub fn loc(&self) -> Option<crate::loc::LocCounts> {
if !self.is_file() {
return None;
}
let lang = self.language()?;
crate::loc::LocCounts::from_path(&self.path, lang)
.ok()
.flatten()
}
/// Whether this file is both a regular file *and* executable for the
/// current user. An executable file has a different purpose from an
/// executable directory, so they should be highlighted differently.
+2
View File
@@ -9,6 +9,8 @@ pub mod fs;
#[allow(unused)]
pub mod info;
#[allow(unused)]
pub mod loc;
#[allow(unused)]
pub mod logger;
#[allow(unused)]
pub mod options;
+622
View File
@@ -0,0 +1,622 @@
// SPDX-FileCopyrightText: 2024 Christina Sørensen
// SPDX-License-Identifier: EUPL-1.2
//
// SPDX-FileCopyrightText: 2023-2024 Christina Sørensen, eza contributors
// SPDX-License-Identifier: MIT
//! Lines-of-code counting.
//!
//! This is eza’s own, dependency-free source-code counter. Given a file’s
//! name we work out which programming language it’s written in (from its
//! extension, or its whole name for files like `Makefile`), and then we walk
//! its contents a line at a time, classifying each physical line as one of:
//!
//! - **code** — a line that contains at least one character of actual source,
//! - **comment** — a line that is entirely comment (line- or block-style),
//! - **blank** — a line that is empty or only whitespace.
//!
//! The counter is deliberately *comment-aware* rather than a naïve line
//! count: it understands each language’s line- and block-comment syntax and
//! skips over string literals so that a `//` inside `"https://…"` is not
//! mistaken for a comment. Its known limitations are that block comments are
//! treated as non-nesting and that only single-line string literals are
//! tracked; both are rare enough in practice not to skew a project’s totals.
use std::collections::BTreeMap;
use std::io;
use std::ops::AddAssign;
use std::path::{Path, PathBuf};
use phf::{Map, phf_map};
use rayon::prelude::*;
/// A programming language eza knows how to count, along with the comment
/// syntax needed to tell code from commentary.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Language {
/// The human-readable name shown in the summary and language column.
pub name: &'static str,
/// Tokens that begin a comment lasting to the end of the line.
pub line_comments: &'static [&'static str],
/// `(open, close)` delimiter pairs for block comments.
pub block_comments: &'static [(&'static str, &'static str)],
}
/// The tally of lines within a single file, or an aggregate across many.
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct LocCounts {
/// Total physical lines. Always equals `code + comments + blanks`.
pub lines: usize,
/// Lines containing at least one character of source code.
pub code: usize,
/// Lines that are entirely comment.
pub comments: usize,
/// Empty or whitespace-only lines.
pub blanks: usize,
}
impl AddAssign for LocCounts {
fn add_assign(&mut self, rhs: Self) {
self.lines += rhs.lines;
self.code += rhs.code;
self.comments += rhs.comments;
self.blanks += rhs.blanks;
}
}
impl LocCounts {
/// Count the lines of the given `source`, using `lang`’s comment rules.
#[must_use]
pub fn from_source(source: &str, lang: &Language) -> Self {
let mut counts = Self::default();
// The block-comment terminator we’re currently hunting for, if any.
// This is threaded across lines so multi-line block comments work.
let mut block: Option<&'static str> = None;
for line in source.lines() {
counts.lines += 1;
let (has_code, has_comment) = classify_line(line, lang, &mut block);
if has_code {
counts.code += 1;
} else if has_comment {
counts.comments += 1;
} else {
counts.blanks += 1;
}
}
counts
}
/// Read `path` and count its lines. Returns `Ok(None)` for files that
/// aren’t valid UTF-8 (i.e. binaries), which we simply don’t count.
pub fn from_path(path: &Path, lang: &Language) -> io::Result<Option<Self>> {
match std::fs::read_to_string(path) {
Ok(source) => Ok(Some(Self::from_source(&source, lang))),
Err(e) if e.kind() == io::ErrorKind::InvalidData => Ok(None),
Err(e) => Err(e),
}
}
}
/// Classify a single physical line, updating `block` with any block-comment
/// state that carries over to the next line. Returns `(has_code, has_comment)`.
fn classify_line(line: &str, lang: &Language, block: &mut Option<&'static str>) -> (bool, bool) {
let mut has_code = false;
let mut has_comment = false;
let mut rest = line;
'scan: loop {
// Inside a block comment: everything up to the closing delimiter is
// comment. If it never closes on this line, the block continues.
if let Some(close) = *block {
has_comment = true;
match rest.find(close) {
Some(pos) => {
rest = &rest[pos + close.len()..];
*block = None;
continue;
}
None => break,
}
}
rest = rest.trim_start();
if rest.is_empty() {
break;
}
// A line comment swallows the remainder of the line.
if lang.line_comments.iter().any(|lc| rest.starts_with(lc)) {
has_comment = true;
break;
}
// A block comment opener starts a (possibly multi-line) comment.
if let Some((open, close)) = lang
.block_comments
.iter()
.find(|(open, _)| rest.starts_with(open))
{
has_comment = true;
*block = Some(close);
rest = &rest[open.len()..];
continue 'scan;
}
// Anything else is code. Consume one unit, skipping over string
// literals so their contents can’t be mistaken for comments.
has_code = true;
let c = rest.chars().next().unwrap();
if c == '"' {
rest = consume_string(&rest[c.len_utf8()..], '"');
} else {
rest = &rest[c.len_utf8()..];
}
}
(has_code, has_comment)
}
/// Consume a string literal body, returning the slice after the closing
/// `quote`. Backslash escapes are honoured; an unterminated string consumes
/// the rest of the line.
fn consume_string(s: &str, quote: char) -> &str {
let mut chars = s.char_indices();
while let Some((i, c)) = chars.next() {
if c == '\\' {
chars.next();
} else if c == quote {
return &s[i + c.len_utf8()..];
}
}
""
}
/// Work out the language of a file from its whole name (for files like
/// `Makefile`) or, failing that, its already-lowercased extension.
#[must_use]
pub fn language_for(name: &str, ext: Option<&str>) -> Option<&'static Language> {
if let Some(lang) = BY_FILENAME.get(name) {
return Some(lang);
}
ext.and_then(|e| BY_EXTENSION.get(e)).copied()
}
/// The aggregate line counts for a single language across many files.
#[derive(Debug, Clone)]
pub struct LangStat {
pub language: &'static Language,
pub files: usize,
pub counts: LocCounts,
/// The name and lowercase extension of one counted file, giving the
/// summary view a representative file to pick the language’s icon from.
pub rep_file: (String, Option<String>),
}
/// The result of counting a whole tree: a per-language breakdown, ordered by
/// language name for stable output.
#[derive(Debug, Default, Clone)]
pub struct Report {
languages: BTreeMap<&'static str, LangStat>,
}
impl Report {
fn add(&mut self, language: &'static Language, counts: LocCounts, path: &Path) {
let stat = self.languages.entry(language.name).or_insert_with(|| {
let name = path
.file_name()
.and_then(|s| s.to_str())
.unwrap_or_default()
.to_string();
let ext = path
.extension()
.and_then(|s| s.to_str())
.map(str::to_ascii_lowercase);
LangStat {
language,
files: 0,
counts: LocCounts::default(),
rep_file: (name, ext),
}
});
stat.files += 1;
stat.counts += counts;
}
/// The per-language rows, ordered by language name.
#[must_use]
pub fn languages(&self) -> impl Iterator<Item = &LangStat> {
self.languages.values()
}
/// The grand total across every language.
#[must_use]
pub fn total(&self) -> LocCounts {
let mut total = LocCounts::default();
for stat in self.languages.values() {
total += stat.counts;
}
total
}
/// The total number of counted files.
#[must_use]
pub fn total_files(&self) -> usize {
self.languages.values().map(|s| s.files).sum()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.languages.is_empty()
}
}
/// Recursively count every recognised source file under `roots`, using
/// `is_ignored` to skip files (e.g. those matched by `.gitignore`). Hidden
/// entries and symbolic links are always skipped, so `.git` and friends never
/// get walked. Counting itself is parallelised across a thread pool.
pub fn count_tree<F>(roots: &[PathBuf], is_ignored: &F) -> Report
where
F: Fn(&Path) -> bool,
{
let mut jobs: Vec<(PathBuf, &'static Language)> = Vec::new();
for root in roots {
collect_jobs(root, is_ignored, &mut jobs);
}
let counted: Vec<(&'static Language, LocCounts, &PathBuf)> = jobs
.par_iter()
.filter_map(|(path, lang)| {
LocCounts::from_path(path, lang)
.ok()
.flatten()
.map(|counts| (*lang, counts, path))
})
.collect();
let mut report = Report::default();
for (lang, counts, path) in counted {
report.add(lang, counts, path);
}
report
}
/// Walk one path, gathering `(file, language)` jobs for every recognised
/// source file beneath it.
fn collect_jobs<F>(path: &Path, is_ignored: &F, jobs: &mut Vec<(PathBuf, &'static Language)>)
where
F: Fn(&Path) -> bool,
{
let Ok(meta) = std::fs::symlink_metadata(path) else {
return;
};
let file_type = meta.file_type();
// Never follow symlinks: it risks cycles and double-counting.
if file_type.is_symlink() {
return;
}
if file_type.is_file() {
let name = path
.file_name()
.and_then(|s| s.to_str())
.unwrap_or_default();
let ext = path
.extension()
.and_then(|s| s.to_str())
.map(str::to_ascii_lowercase);
if let Some(lang) = language_for(name, ext.as_deref()) {
jobs.push((path.to_path_buf(), lang));
}
return;
}
if file_type.is_dir() {
let Ok(entries) = std::fs::read_dir(path) else {
return;
};
for entry in entries.flatten() {
// Skip hidden entries by default — this also keeps us out of `.git`.
if entry.file_name().to_string_lossy().starts_with('.') {
continue;
}
let child = entry.path();
if is_ignored(&child) {
continue;
}
collect_jobs(&child, is_ignored, jobs);
}
}
}
/// Count the given `roots`, respecting a repository’s `.gitignore` if the
/// `git` feature is enabled and the roots live inside one. This is the entry
/// point used by both the `--loc` percentage columns and the `--code` summary.
#[must_use]
pub fn count_roots(roots: &[PathBuf]) -> Report {
#[cfg(feature = "git")]
{
if let Some(first) = roots.first()
&& let Ok(repo) = git2::Repository::discover(first)
{
// `is_path_ignored` wants a path it can resolve against the work
// tree; a `./`-prefixed relative path (as produced by walking `.`)
// confuses it, so canonicalise first, falling back to the raw path.
let is_ignored = |p: &Path| {
let resolved = std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
repo.is_path_ignored(&resolved).unwrap_or(false)
};
return count_tree(roots, &is_ignored);
}
}
count_tree(roots, &|_: &Path| false)
}
// Comment-syntax building blocks, shared between the many languages that use
// the same conventions.
const C_LINE: &[&str] = &["//"];
const C_BLOCK: &[(&str, &str)] = &[("/*", "*/")];
const HASH_LINE: &[&str] = &["#"];
const NO_BLOCK: &[(&str, &str)] = &[];
macro_rules! languages {
($( $konst:ident = ($name:literal, $line:expr, $block:expr); )*) => {
$(
static $konst: Language = Language {
name: $name,
line_comments: $line,
block_comments: $block,
};
)*
};
}
languages! {
RUST = ("Rust", C_LINE, &[("/*", "*/")]);
C = ("C", C_LINE, C_BLOCK);
CPP = ("C++", C_LINE, C_BLOCK);
CSHARP = ("C#", C_LINE, C_BLOCK);
JAVA = ("Java", C_LINE, C_BLOCK);
KOTLIN = ("Kotlin", C_LINE, C_BLOCK);
SCALA = ("Scala", C_LINE, C_BLOCK);
SWIFT = ("Swift", C_LINE, C_BLOCK);
GO = ("Go", C_LINE, C_BLOCK);
JAVASCRIPT = ("JavaScript", C_LINE, C_BLOCK);
TYPESCRIPT = ("TypeScript", C_LINE, C_BLOCK);
JSX = ("JSX", C_LINE, C_BLOCK);
TSX = ("TSX", C_LINE, C_BLOCK);
DART = ("Dart", C_LINE, C_BLOCK);
ZIG = ("Zig", C_LINE, NO_BLOCK);
OBJC = ("Objective-C", C_LINE, C_BLOCK);
PHP = ("PHP", &["//", "#"], C_BLOCK);
CSS = ("CSS", NO_LINE, C_BLOCK);
SCSS = ("SCSS", C_LINE, C_BLOCK);
GLSL = ("GLSL", C_LINE, C_BLOCK);
PYTHON = ("Python", HASH_LINE, &[("\"\"\"", "\"\"\""), ("'''", "'''")]);
RUBY = ("Ruby", HASH_LINE, &[("=begin", "=end")]);
PERL = ("Perl", HASH_LINE, &[("=pod", "=cut")]);
SHELL = ("Shell", HASH_LINE, NO_BLOCK);
FISH = ("Fish", HASH_LINE, NO_BLOCK);
POWERSHELL = ("PowerShell", HASH_LINE, &[("<#", "#>")]);
LUA = ("Lua", &["--"], &[("--[[", "]]")]);
HASKELL = ("Haskell", &["--"], &[("{-", "-}")]);
ELM = ("Elm", &["--"], &[("{-", "-}")]);
SQL = ("SQL", &["--"], C_BLOCK);
NIX = ("Nix", HASH_LINE, C_BLOCK);
TOML = ("TOML", HASH_LINE, NO_BLOCK);
YAML = ("YAML", HASH_LINE, NO_BLOCK);
JSON = ("JSON", NO_LINE, NO_BLOCK);
MARKDOWN = ("Markdown", NO_LINE, NO_BLOCK);
HTML = ("HTML", NO_LINE, &[("<!--", "-->")]);
XML = ("XML", NO_LINE, &[("<!--", "-->")]);
ELIXIR = ("Elixir", HASH_LINE, NO_BLOCK);
ERLANG = ("Erlang", &["%"], NO_BLOCK);
CLOJURE = ("Clojure", &[";"], NO_BLOCK);
LISP = ("Lisp", &[";"], &[("#|", "|#")]);
SCHEME = ("Scheme", &[";"], &[("#|", "|#")]);
OCAML = ("OCaml", NO_LINE, &[("(*", "*)")]);
FSHARP = ("F#", C_LINE, &[("(*", "*)")]);
VIM = ("Vim script", &["\""], NO_BLOCK);
MAKE = ("Makefile", HASH_LINE, NO_BLOCK);
DOCKER = ("Dockerfile", HASH_LINE, NO_BLOCK);
TEX = ("TeX", &["%"], NO_BLOCK);
R = ("R", HASH_LINE, NO_BLOCK);
JULIA = ("Julia", HASH_LINE, &[("#=", "=#")]);
ASSEMBLY = ("Assembly", &[";"], NO_BLOCK);
PROTOBUF = ("Protocol Buffers", C_LINE, C_BLOCK);
}
const NO_LINE: &[&str] = &[];
/// Look-up from an exact file name to its language.
static BY_FILENAME: Map<&'static str, &'static Language> = phf_map! {
"Makefile" => &MAKE,
"makefile" => &MAKE,
"GNUmakefile" => &MAKE,
"Dockerfile" => &DOCKER,
"Containerfile" => &DOCKER,
"Rakefile" => &RUBY,
"Gemfile" => &RUBY,
"CMakeLists.txt" => &MAKE,
};
/// Look-up from a (lowercase) extension to its language.
static BY_EXTENSION: Map<&'static str, &'static Language> = phf_map! {
"rs" => &RUST,
"c" => &C,
"h" => &C,
"cc" => &CPP,
"cpp" => &CPP,
"cxx" => &CPP,
"hpp" => &CPP,
"hh" => &CPP,
"cs" => &CSHARP,
"java" => &JAVA,
"kt" => &KOTLIN,
"kts" => &KOTLIN,
"scala" => &SCALA,
"sc" => &SCALA,
"swift" => &SWIFT,
"go" => &GO,
"js" => &JAVASCRIPT,
"mjs" => &JAVASCRIPT,
"cjs" => &JAVASCRIPT,
"ts" => &TYPESCRIPT,
"jsx" => &JSX,
"tsx" => &TSX,
"dart" => &DART,
"zig" => &ZIG,
"m" => &OBJC,
"mm" => &OBJC,
"php" => &PHP,
"css" => &CSS,
"scss" => &SCSS,
"sass" => &SCSS,
"glsl" => &GLSL,
"vert" => &GLSL,
"frag" => &GLSL,
"py" => &PYTHON,
"pyw" => &PYTHON,
"rb" => &RUBY,
"pl" => &PERL,
"pm" => &PERL,
"sh" => &SHELL,
"bash" => &SHELL,
"zsh" => &SHELL,
"ksh" => &SHELL,
"fish" => &FISH,
"ps1" => &POWERSHELL,
"psm1" => &POWERSHELL,
"lua" => &LUA,
"hs" => &HASKELL,
"elm" => &ELM,
"sql" => &SQL,
"nix" => &NIX,
"toml" => &TOML,
"yaml" => &YAML,
"yml" => &YAML,
"json" => &JSON,
"md" => &MARKDOWN,
"markdown" => &MARKDOWN,
"html" => &HTML,
"htm" => &HTML,
"xml" => &XML,
"ex" => &ELIXIR,
"exs" => &ELIXIR,
"erl" => &ERLANG,
"hrl" => &ERLANG,
"clj" => &CLOJURE,
"cljs" => &CLOJURE,
"lisp" => &LISP,
"el" => &LISP,
"scm" => &SCHEME,
"ml" => &OCAML,
"mli" => &OCAML,
"fs" => &FSHARP,
"fsx" => &FSHARP,
"vim" => &VIM,
"tex" => &TEX,
"r" => &R,
"jl" => &JULIA,
"s" => &ASSEMBLY,
"asm" => &ASSEMBLY,
"proto" => &PROTOBUF,
};
#[cfg(test)]
mod test {
use super::*;
fn count(source: &str, lang: &Language) -> LocCounts {
LocCounts::from_source(source, lang)
}
#[test]
fn empty_file_is_all_zero() {
assert_eq!(count("", &RUST), LocCounts::default());
}
#[test]
fn totals_always_add_up() {
let c = count("fn main() {}\n\n// hi\n", &RUST);
assert_eq!(c.lines, c.code + c.comments + c.blanks);
}
#[test]
fn counts_code_comments_and_blanks() {
let source = "fn main() {\n // a comment\n\n println!(\"hi\");\n}\n";
let c = count(source, &RUST);
assert_eq!(
c,
LocCounts {
lines: 5,
code: 3,
comments: 1,
blanks: 1,
}
);
}
#[test]
fn block_comments_span_lines() {
let source = "code();\n/* start\nstill comment\nend */\nmore();\n";
let c = count(source, &C);
assert_eq!(
c,
LocCounts {
lines: 5,
code: 2,
comments: 3,
blanks: 0,
}
);
}
#[test]
fn code_then_trailing_comment_is_code() {
let c = count("let x = 1; // set x\n", &RUST);
assert_eq!(c.code, 1);
assert_eq!(c.comments, 0);
}
#[test]
fn comment_token_inside_string_is_code() {
let c = count("let url = \"https://example.com\";\n", &RUST);
assert_eq!(c.code, 1);
assert_eq!(c.comments, 0);
}
#[test]
fn block_open_and_close_on_same_line_after_code() {
let c = count("do_thing(); /* inline */ do_more();\n", &C);
assert_eq!(c.code, 1);
assert_eq!(c.comments, 0);
}
#[test]
fn hash_languages() {
let c = count("# comment\nname = 1\n", &TOML);
assert_eq!(c.code, 1);
assert_eq!(c.comments, 1);
}
#[test]
fn detects_language_by_extension() {
assert_eq!(language_for("main.rs", Some("rs")), Some(&RUST));
assert_eq!(language_for("app.py", Some("py")), Some(&PYTHON));
assert_eq!(language_for("mystery.xyz", Some("xyz")), None);
}
#[test]
fn detects_language_by_filename() {
assert_eq!(language_for("Makefile", None), Some(&MAKE));
assert_eq!(language_for("Dockerfile", Some("")), Some(&DOCKER));
}
}
+44 -1
View File
@@ -23,12 +23,13 @@ use crate::fs::filter::{FileFilterFlags::OnlyFiles, GitIgnore};
use crate::fs::{Dir, File};
use crate::options::stdin::FilesInput;
use crate::options::{Options, Vars, vars};
use crate::output::{Mode, View, details, escape, file_name, grid, grid_details, lines};
use crate::output::{Mode, View, code, details, escape, file_name, grid, grid_details, lines};
use crate::theme::Theme;
use log::*;
mod fs;
mod info;
mod loc;
mod logger;
mod options;
mod output;
@@ -226,6 +227,44 @@ impl Exa<'_> {
pub fn run(mut self) -> io::Result<i32> {
debug!("Running with options: {:#?}", self.options);
// The `--code` summary doesn’t list files: it walks the given paths and
// prints a per-language lines-of-code breakdown, so handle it up front.
if let Mode::Code(opts) = &self.options.view.mode {
let opts = *opts;
let mut exit_status = 0;
let mut roots = Vec::new();
// Report paths that don’t exist, like the normal listing does,
// and count the rest.
for file_path in &self.input_paths {
let path = PathBuf::from(file_path);
if let Err(e) = std::fs::symlink_metadata(&path) {
exit_status = 2;
writeln!(io::stderr(), "{file_path:?}: {e}")?;
} else {
roots.push(path);
}
}
let file_style = &self.options.view.file_style;
let show_icons = match file_style.show_icons {
file_name::ShowIcons::Always(_) => true,
file_name::ShowIcons::Automatic(_) => file_style.is_a_tty,
file_name::ShowIcons::Never => false,
};
if roots.is_empty() {
return Ok(exit_status);
}
let r = code::Render {
theme: &self.theme,
opts: &opts,
roots,
show_icons,
};
r.render(&mut self.writer)?;
return Ok(exit_status);
}
let mut files = Vec::new();
let mut dirs = Vec::new();
let mut exit_status = 0;
@@ -512,6 +551,10 @@ impl Exa<'_> {
};
r.render(&mut self.writer)
}
// The code summary never lists files; it’s handled up front in
// `run` before we ever get here.
(Mode::Code(_), _) => unreachable!("--code is handled in Exa::run"),
}
}
}
+21
View File
@@ -49,6 +49,12 @@ pub fn get_command() -> clap::Command {
.arg(arg!(-T --tree "recurse into directories as a tree"))
.arg(arg!(-L --level <DEPTH> "limit the depth of recursion")
.value_parser(value_parser!(usize)))
.arg(arg!(--code <MODE> "summarise lines of code by language, recursing the tree or git repo")
.num_args(0..=1)
.require_equals(true)
.value_parser(value_parser!(CodeContent))
.default_missing_value("both")
.hide_possible_values(true))
.arg(arg!(--"follow-symlinks" "drill down into symbolic links that point to directories"))
.arg(arg!(-w --width <COLS> "set screen width in columns")
.value_parser(value_parser!(usize)))
@@ -118,6 +124,12 @@ pub fn get_command() -> clap::Command {
.next_help_heading("LONG VIEW OPTIONS")
.arg(arg!(-h --header "add a header row to each column"))
.arg(arg!(-i --inode "list each file's inode number"))
.arg(arg!(--loc <MODE> "add lines-of-code and language columns [modes: lines, percent, both]")
.num_args(0..=1)
.require_equals(true)
.value_parser(value_parser!(CodeContent))
.default_missing_value("both")
.hide_possible_values(true))
.arg(arg!(-o --"octal-permissions" "list each file's permission in octal format"))
.arg(arg!(-H --links "list each file's number of hard links"))
.arg(arg!(-b --binary "show file sizes with binary prefixes"))
@@ -200,6 +212,15 @@ pub enum ColorScaleModeArgs {
Gradient,
}
/// What the `--loc` columns and `--code` summary should display: raw line
/// counts, each language’s share as a percentage, or both side by side.
#[derive(Clone, Copy, Debug, ValueEnum, PartialEq, Eq)]
pub enum CodeContent {
Lines,
Percent,
Both,
}
impl ValueEnum for SortField {
fn value_variants<'a>() -> &'a [Self] {
&[
+72 -2
View File
@@ -9,7 +9,7 @@ use clap::{ArgMatches, ValueEnum};
use crate::output::TerminalWidth::Automatic;
use crate::fs::feature::xattr;
use crate::options::parser::ColorScaleModeArgs;
use crate::options::parser::{CodeContent, ColorScaleModeArgs};
use crate::options::{NumberSource, OptionsError, Vars, vars};
use crate::output::TerminalWidth::Set;
use crate::output::color_scale::{ColorScaleMode, ColorScaleOptions};
@@ -19,7 +19,7 @@ use crate::output::table::{
Columns, FlagsFormat, GroupFormat, Options as TableOptions, SizeFormat, TimeTypes, UserFormat,
};
use crate::output::time::TimeFormat;
use crate::output::{Mode, TerminalWidth, View, details, grid};
use crate::output::{Mode, TerminalWidth, View, code, details, grid};
use super::parser::{ColorScaleArgs, TimeArgs};
@@ -62,6 +62,12 @@ impl Mode {
is_tty: bool,
strict: bool,
) -> Result<Self, OptionsError> {
// `--code` is its own standalone tool: it summarises languages rather
// than listing files, so it takes precedence over the layout flags.
if let Some(content) = matches.get_one::<CodeContent>("code").copied() {
return Ok(Self::Code(code::Options { content }));
}
let long = matches.get_flag("long");
let oneline = matches.get_flag("oneline");
let grid = matches.get_flag("grid");
@@ -123,6 +129,7 @@ impl Mode {
"group",
"numeric",
"mounts",
"loc",
] {
if matches.contains_id(flag) {
return Err(OptionsError::Useless(flag, false, "long"));
@@ -279,6 +286,8 @@ impl Columns {
let filesize = !matches.get_flag("no-filesize");
let user = !matches.get_flag("no-user");
let loc = matches.get_one::<CodeContent>("loc").copied();
Ok(Self {
time_types,
inode,
@@ -294,6 +303,7 @@ impl Columns {
permissions,
filesize,
user,
loc,
})
}
}
@@ -1076,4 +1086,64 @@ mod tests {
))
);
}
#[test]
fn deduce_mode_code_default_is_both() {
assert_eq!(
Mode::deduce(
&mock_cli(vec!["--code"]),
&MockVars::default(),
false,
false
),
Ok(Mode::Code(code::Options {
content: CodeContent::Both
}))
);
}
#[test]
fn deduce_mode_code_lines() {
assert_eq!(
Mode::deduce(
&mock_cli(vec!["--code=lines"]),
&MockVars::default(),
false,
false
),
Ok(Mode::Code(code::Options {
content: CodeContent::Lines
}))
);
}
#[test]
fn deduce_columns_loc_percent() {
assert_eq!(
Columns::deduce(&mock_cli(vec!["--loc=percent"]), &MockVars::default())
.unwrap()
.loc,
Some(CodeContent::Percent)
);
}
#[test]
fn deduce_columns_loc_bare_is_both() {
assert_eq!(
Columns::deduce(&mock_cli(vec!["--loc"]), &MockVars::default())
.unwrap()
.loc,
Some(CodeContent::Both)
);
}
#[test]
fn deduce_columns_loc_absent() {
assert_eq!(
Columns::deduce(&mock_cli(vec![""]), &MockVars::default())
.unwrap()
.loc,
None
);
}
}
+309
View File
@@ -0,0 +1,309 @@
// SPDX-FileCopyrightText: 2024 Christina Sørensen
// SPDX-License-Identifier: EUPL-1.2
//
// SPDX-FileCopyrightText: 2023-2024 Christina Sørensen, eza contributors
// SPDX-License-Identifier: MIT
//! The **Code** view: a standalone lines-of-code summary, in the spirit of
//! tools like `tokei` and `cloc`.
//!
//! Unlike the other views it doesn’t list files at all. Instead it walks the
//! given paths (or the current directory) recursively — honouring a git
//! repository’s `.gitignore` when there is one — counts every recognised
//! source file, and prints one row per language with the project totals
//! underneath.
//!
//! It borrows eza’s long-view look: an underlined header row rather than
//! boxes, icons when they’re enabled, locale-aware number formatting, and a
//! block-character bar visualising each language’s share of the code.
use std::io::{self, Write};
use std::path::PathBuf;
use nu_ansi_term::Style;
use crate::loc::LangStat;
use crate::options::parser::CodeContent;
use crate::output::icons::{icon_for_name_ext, iconify_style};
use crate::theme::Theme;
/// The width, in cells, of the share bar next to the percentage column.
const BAR_WIDTH: usize = 16;
/// Eighth-block characters, from one eighth to a full block, used to give
/// the share bar sub-cell resolution.
const EIGHTHS: [char; 8] = ['▏', '▎', '▍', '▌', '▋', '▊', '▉', '█'];
/// Options for the code-summary view.
#[derive(PartialEq, Eq, Debug, Copy, Clone)]
pub struct Options {
/// Whether to show line counts, percentages, or both.
pub content: CodeContent,
}
/// Everything needed to render a code summary.
pub struct Render<'a> {
pub theme: &'a Theme,
pub opts: &'a Options,
/// The paths to count, recursively. Empty means the current directory.
pub roots: Vec<PathBuf>,
/// Whether to prefix each language with a representative file icon.
pub show_icons: bool,
}
/// How a summary column lines its contents up.
#[derive(Copy, Clone, PartialEq, Eq)]
enum Align {
Left,
Right,
}
/// One fully-styled cell, ready to be measured and painted.
struct Cell {
text: String,
style: Style,
align: Align,
}
impl Cell {
fn new(text: String, style: Style, align: Align) -> Self {
Self { text, style, align }
}
/// The display width of this cell. Every character we emit — digits,
/// letters, and block glyphs — occupies one terminal cell.
fn width(&self) -> usize {
self.text.chars().count()
}
}
impl Render<'_> {
pub fn render<W: Write>(self, w: &mut W) -> io::Result<()> {
let report = crate::loc::count_roots(&self.roots);
if report.is_empty() {
let style = self.theme.ui.punctuation.unwrap_or_default();
return writeln!(w, "{}", style.paint("No recognised source code found."));
}
let numerics =
locale::Numeric::load_user_locale().unwrap_or_else(|_| locale::Numeric::english());
// The eza-flavoured palette: quantities take the size colour, the
// language names the date colour, and structure stays dim.
let header = self.theme.ui.header.unwrap_or_default();
let lang_style = self.theme.ui.date.unwrap_or_default();
let count_style = self
.theme
.ui
.size
.unwrap_or_default()
.number_byte
.unwrap_or_default();
let dim = self.theme.ui.punctuation.unwrap_or_default();
let bar_style = self
.theme
.ui
.filekinds
.unwrap_or_default()
.directory
.unwrap_or_default();
// Bold the totals only when colours are on at all, so piped output
// stays free of escape codes.
let total_style = if self.theme.ui.colourful.unwrap_or_default() {
Style::default().bold()
} else {
Style::default()
};
let with_lines = matches!(self.opts.content, CodeContent::Lines | CodeContent::Both);
let with_percent = matches!(self.opts.content, CodeContent::Percent | CodeContent::Both);
// Languages sorted by most code first, then by name for stability.
let mut langs: Vec<&LangStat> = report.languages().collect();
langs.sort_by(|a, b| {
b.counts
.code
.cmp(&a.counts.code)
.then_with(|| a.language.name.cmp(b.language.name))
});
let total = report.total();
let max_code = langs.first().map_or(0, |s| s.counts.code);
// The icon column prefix: icons get two cells (glyph + space), and
// every icon-less row gets two spaces so the names stay aligned.
let lang_cell = |stat: &LangStat| {
let name = stat.language.name;
if self.show_icons {
let (rep_name, rep_ext) = &stat.rep_file;
let icon = icon_for_name_ext(rep_name, rep_ext.as_deref());
format!("{icon} {name}")
} else {
name.to_string()
}
};
let plain_lang = |name: &str| {
if self.show_icons {
format!(" {name}")
} else {
name.to_string()
}
};
let num = |n: usize, style: Style| Cell::new(numerics.format_int(n), style, Align::Right);
let pct = |part: usize, style: Style| {
let text = if total.code == 0 {
"-".to_string()
} else {
format!("{:.1}%", (part as f64) * 100.0 / (total.code as f64))
};
Cell::new(text, style, Align::Right)
};
// Build every row up front so each column can be sized to fit.
let mut header_row = vec![Cell::new(plain_lang("Language"), header, Align::Left)];
let mut body: Vec<Vec<Cell>> = langs
.iter()
.map(|s| vec![Cell::new(lang_cell(s), lang_style, Align::Left)])
.collect();
let mut total_row = vec![Cell::new(plain_lang("Total"), total_style, Align::Left)];
header_row.push(Cell::new("Files".into(), header, Align::Right));
for (row, s) in body.iter_mut().zip(&langs) {
row.push(num(s.files, count_style));
}
total_row.push(num(report.total_files(), total_style));
if with_lines {
type Get = fn(&LangStat) -> usize;
let metrics: [(&str, Get); 4] = [
("Lines", |s| s.counts.lines),
("Code", |s| s.counts.code),
("Comments", |s| s.counts.comments),
("Blanks", |s| s.counts.blanks),
];
for (title, get) in metrics {
header_row.push(Cell::new(title.into(), header, Align::Right));
let style = if title == "Code" { count_style } else { dim };
for (row, s) in body.iter_mut().zip(&langs) {
row.push(num(get(s), style));
}
}
total_row.push(num(total.lines, total_style));
total_row.push(num(total.code, total_style));
total_row.push(num(total.comments, total_style));
total_row.push(num(total.blanks, total_style));
}
if with_percent {
header_row.push(Cell::new("Code %".into(), header, Align::Right));
for (row, s) in body.iter_mut().zip(&langs) {
row.push(pct(s.counts.code, count_style));
}
total_row.push(pct(total.code, total_style));
// The share bar: scaled against the largest language, so the top
// row always spans the full bar width.
header_row.push(Cell::new(String::new(), header, Align::Left));
for (row, s) in body.iter_mut().zip(&langs) {
row.push(Cell::new(
bar(s.counts.code, max_code),
bar_style,
Align::Left,
));
}
total_row.push(Cell::new(String::new(), total_style, Align::Left));
}
// Size each column to its widest cell.
let columns = header_row.len();
let mut widths = vec![0; columns];
for row in std::iter::once(&header_row)
.chain(body.iter())
.chain(std::iter::once(&total_row))
{
for (width, cell) in widths.iter_mut().zip(row.iter()) {
*width = (*width).max(cell.width());
}
}
writeln!(w, "{}", paint_row(&header_row, &widths, self.show_icons))?;
for row in &body {
writeln!(w, "{}", paint_row(row, &widths, self.show_icons))?;
}
let rule_width = 1 + widths.iter().sum::<usize>() + 2 * (columns - 1);
writeln!(w, "{}", dim.paint("─".repeat(rule_width)))?;
writeln!(w, "{}", paint_row(&total_row, &widths, self.show_icons))?;
Ok(())
}
}
/// Draw a bar visualising `value` against the largest value in the table,
/// with eighth-block resolution. Any non-zero value gets at least a sliver.
fn bar(value: usize, max: usize) -> String {
if max == 0 || value == 0 {
return String::new();
}
#[allow(clippy::cast_precision_loss, clippy::cast_sign_loss)]
let units = (((value as f64) / (max as f64)) * ((BAR_WIDTH * 8) as f64)).round() as usize;
let units = units.max(1);
let mut bar = "█".repeat(units / 8);
if units % 8 > 0 {
bar.push(EIGHTHS[units % 8 - 1]);
}
bar
}
/// Paint one row: a leading space, then each cell padded to its column width
/// and separated by two spaces. Trailing whitespace is trimmed so bars and
/// short final cells don’t leave invisible padding behind.
fn paint_row(cells: &[Cell], widths: &[usize], iconify_first: bool) -> String {
let mut out = String::from(" ");
for (i, cell) in cells.iter().enumerate() {
if i > 0 {
out.push_str(" ");
}
let padding = " ".repeat(widths[i].saturating_sub(cell.width()));
// An empty cell is pure padding: don’t emit pointless colour codes.
if cell.text.is_empty() {
out.push_str(&padding);
continue;
}
let painted = if i == 0 && iconify_first && cell.width() > 2 {
// Paint the icon prefix separately from the name, so underlined
// headers don’t drag the underline through the icon column, and
// icons keep only the colour of the style they accompany.
let split = cell
.text
.char_indices()
.nth(2)
.map_or(cell.text.len(), |(pos, _)| pos);
let (prefix, name) = cell.text.split_at(split);
format!(
"{}{}",
iconify_style(cell.style).paint(prefix),
cell.style.paint(name)
)
} else {
cell.style.paint(cell.text.as_str()).to_string()
};
match cell.align {
Align::Left => {
out.push_str(&painted);
out.push_str(&padding);
}
Align::Right => {
out.push_str(&padding);
out.push_str(&painted);
}
}
}
out.truncate(out.trim_end().len());
out
}
+19
View File
@@ -80,6 +80,7 @@ use crate::fs::feature::xattr::Attribute;
use crate::fs::fields::SecurityContextType;
use crate::fs::filter::FileFilter;
use crate::fs::{Dir, File};
use crate::options::parser::CodeContent;
use crate::output::cell::TextCell;
use crate::output::color_scale::{ColorScaleInformation, ColorScaleOptions};
use crate::output::file_name::Options as FileStyle;
@@ -192,8 +193,16 @@ impl<'a> Render<'a> {
(None, _) => { /* Keep Git how it is */ }
}
let loc_content = table.columns.loc;
let mut table = Table::new(table, self.git, self.theme, self.git_repos);
// Percentage columns need the whole tree’s code total as their
// denominator, so walk the tree (or git repo) once up front.
if matches!(loc_content, Some(CodeContent::Percent | CodeContent::Both)) {
let report = crate::loc::count_roots(&self.loc_roots());
table.set_loc_total(Some(report.total().code));
}
if self.opts.header {
let header = table.header_row();
table.add_widths(&header);
@@ -231,6 +240,16 @@ impl<'a> Render<'a> {
Ok(())
}
/// The root path(s) to recurse when computing the `--loc` percentage
/// denominator: the directory being listed, or the explicit files given.
fn loc_roots(&self) -> Vec<PathBuf> {
if let Some(dir) = self.dir {
vec![dir.path.clone()]
} else {
self.files.iter().map(|f| f.path.clone()).collect()
}
}
/// Whether to show the extended attribute hint
pub fn show_xattr_hint(&self, file: &File<'_>) -> bool {
// Do not show the hint '@' if the only extended attribute is the security
+16
View File
@@ -15,6 +15,7 @@ use term_grid as grid;
use crate::fs::feature::git::GitCache;
use crate::fs::filter::FileFilter;
use crate::fs::{Dir, File};
use crate::options::parser::CodeContent;
use crate::output::cell::TextCell;
use crate::output::color_scale::ColorScaleInformation;
use crate::output::details::{Options as DetailsOptions, Render as DetailsRender};
@@ -258,6 +259,21 @@ impl<'a> Render<'a> {
let mut table = Table::new(options, self.git, self.theme, self.git_repos);
// The `--loc` percentage columns need the whole tree’s code total as
// their denominator, just like in the details view.
if matches!(
options.columns.loc,
Some(CodeContent::Percent | CodeContent::Both)
) {
let roots: Vec<std::path::PathBuf> = if let Some(dir) = self.dir {
vec![dir.path.clone()]
} else {
self.files.iter().map(|f| f.path.clone()).collect()
};
let report = crate::loc::count_roots(&roots);
table.set_loc_total(Some(report.total().code));
}
// The header row will be printed separately, but it should be
// considered for the width calculations.
if self.details.header {
+13
View File
@@ -1123,6 +1123,19 @@ pub fn iconify_style(style: Style) -> Style {
.unwrap_or_default()
}
/// Lookup the icon for a plain file name and lowercase extension, without
/// needing a whole `File`. Used by the `--code` summary to give each
/// language the icon of a representative source file.
pub fn icon_for_name_ext(name: &str, ext: Option<&str>) -> char {
if let Some(icon) = FILENAME_ICONS.get(name) {
*icon
} else if let Some(ext) = ext {
*EXTENSION_ICONS.get(ext).unwrap_or(&Icons::FILE) //
} else {
Icons::FILE_UNKNOW // 󰡯
}
}
/// Lookup the icon for a file based on the file's name, if the entry is a
/// directory, or by the lowercase file extension.
pub fn icon_for_file(file: &File<'_>) -> char {
+4
View File
@@ -8,6 +8,7 @@
pub use self::cell::TextCell;
pub use self::escape::escape;
pub mod code;
pub mod color_scale;
pub mod details;
pub mod file_name;
@@ -42,6 +43,9 @@ pub enum Mode {
Details(details::Options),
GridDetails(grid_details::Options),
Lines,
/// The `--code` lines-of-code summary, which lists languages rather than
/// files.
Code(code::Options),
}
/// The width of the terminal requested by the user.
+88 -4
View File
@@ -19,6 +19,7 @@ use uzers::UsersCache;
use crate::fs::feature::git::GitCache;
use crate::fs::{File, fields as f};
use crate::options::Vars;
use crate::options::parser::CodeContent;
use crate::options::vars::EZA_WINDOWS_ATTRIBUTES;
use crate::output::cell::TextCell;
use crate::output::color_scale::ColorScaleInformation;
@@ -64,6 +65,10 @@ pub struct Columns {
pub permissions: bool,
pub filesize: bool,
pub user: bool,
/// If set, add a language column and a lines-of-code column (in the
/// requested style) to the long view.
pub loc: Option<CodeContent>,
}
impl Columns {
@@ -94,6 +99,18 @@ impl Columns {
columns.push(Column::FileSize);
}
if let Some(content) = self.loc {
columns.push(Column::Language);
match content {
CodeContent::Lines => columns.push(Column::Loc(CodeContent::Lines)),
CodeContent::Percent => columns.push(Column::Loc(CodeContent::Percent)),
CodeContent::Both => {
columns.push(Column::Loc(CodeContent::Lines));
columns.push(Column::Loc(CodeContent::Percent));
}
}
}
if self.blocksize {
#[cfg(unix)]
columns.push(Column::Blocksize);
@@ -155,6 +172,8 @@ impl Columns {
pub enum Column {
Permissions,
FileSize,
Language,
Loc(CodeContent),
Timestamp(TimeType),
#[cfg(unix)]
Blocksize,
@@ -190,9 +209,12 @@ impl Column {
pub fn alignment(self) -> Alignment {
#[allow(clippy::wildcard_in_or_patterns)]
match self {
Self::FileSize | Self::HardLinks | Self::Inode | Self::Blocksize | Self::GitStatus => {
Alignment::Right
}
Self::FileSize
| Self::HardLinks
| Self::Inode
| Self::Blocksize
| Self::GitStatus
| Self::Loc(_) => Alignment::Right,
Self::Timestamp(_) | _ => Alignment::Left,
}
}
@@ -200,7 +222,7 @@ impl Column {
#[cfg(windows)]
pub fn alignment(self) -> Alignment {
match self {
Self::FileSize | Self::GitStatus => Alignment::Right,
Self::FileSize | Self::GitStatus | Self::Loc(_) => Alignment::Right,
_ => Alignment::Left,
}
}
@@ -215,6 +237,9 @@ impl Column {
#[cfg(windows)]
Self::Permissions => "Mode",
Self::FileSize => "Size",
Self::Language => "Language",
Self::Loc(CodeContent::Percent) => "Code %",
Self::Loc(_) => "Code",
Self::Timestamp(t) => t.header(),
#[cfg(unix)]
Self::Blocksize => "Blocksize",
@@ -417,6 +442,10 @@ pub struct Table<'a> {
group_format: GroupFormat,
flags_format: FlagsFormat,
git: Option<&'a GitCache>,
/// The grand total of code lines used as the denominator for `--loc`
/// percentage columns. `None` until computed by the details renderer.
loc_total: Option<usize>,
}
#[derive(Clone)]
@@ -451,9 +480,16 @@ impl<'a> Table<'a> {
#[cfg(unix)]
group_format: options.group_format,
flags_format: options.flags_format,
loc_total: None,
}
}
/// Set the grand total of code lines, the denominator for `--loc`
/// percentage columns.
pub fn set_loc_total(&mut self, total: Option<usize>) {
self.loc_total = total;
}
#[must_use]
pub fn widths(&self) -> &TableWidths {
&self.widths
@@ -530,6 +566,8 @@ impl<'a> Table<'a> {
&self.env.numeric,
color_scale_info,
),
Column::Language => self.language(file),
Column::Loc(content) => self.loc(file, content),
#[cfg(unix)]
Column::HardLinks => file.links().render(self.theme, &self.env.numeric),
#[cfg(unix)]
@@ -581,6 +619,52 @@ impl<'a> Table<'a> {
}
}
/// The language column: the recognised language’s name, or a dash.
fn language(&self, file: &File<'_>) -> TextCell {
match file.language() {
Some(lang) => TextCell::paint(
self.theme.ui.date.unwrap_or_default(),
lang.name.to_string(),
),
None => self.loc_placeholder(),
}
}
/// A lines-of-code column, rendered as a raw code-line count or as a
/// percentage of the whole tree’s code, depending on `content`.
fn loc(&self, file: &File<'_>, content: CodeContent) -> TextCell {
let Some(counts) = file.loc() else {
return self.loc_placeholder();
};
// Quantities take the same colour as file sizes, so the Code column
// reads consistently next to Size.
let style = self
.theme
.ui
.size
.unwrap_or_default()
.number_byte
.unwrap_or_default();
match content {
CodeContent::Percent => match self.loc_total {
Some(total) if total > 0 => {
let pct = (counts.code as f64) * 100.0 / (total as f64);
TextCell::paint(style, format!("{pct:.1}%"))
}
_ => self.loc_placeholder(),
},
_ => TextCell::paint(style, self.env.numeric.format_int(counts.code)),
}
}
/// The placeholder shown for files with no language or no count.
fn loc_placeholder(&self) -> TextCell {
TextCell::paint(
self.theme.ui.punctuation.unwrap_or_default(),
"-".to_string(),
)
}
fn git_status(&self, file: &File<'_>) -> f::Git {
debug!("Getting Git status for file {:?}", file.path);
View File
+6
View File
@@ -0,0 +1,6 @@
Language Files Lines Code Comments Blanks Code %
Rust 1 8 4 3 1 57.1% ████████████████
Markdown 1 3 2 0 1 28.6% ████████
Python 1 2 1 1 0 14.3% ████
─────────────────────────────────────────────────────────────────────────
Total 3 13 7 4 2 100.0%
+4
View File
@@ -0,0 +1,4 @@
bin.name = "eza"
args = "--code tests/itest-loc"
[env.add]
LC_ALL = "C"
+1
View File
@@ -0,0 +1 @@
No recognised source code found.
+4
View File
@@ -0,0 +1,4 @@
bin.name = "eza"
args = "--code tests/itest-loc/empty"
[env.add]
LC_ALL = "C"
+6
View File
@@ -0,0 +1,6 @@
Language Files Lines Code Comments Blanks
Rust 1 8 4 3 1
Markdown 1 3 2 0 1
Python 1 2 1 1 0
───────────────────────────────────────────────
Total 3 13 7 4 2
+4
View File
@@ -0,0 +1,4 @@
bin.name = "eza"
args = "--code=lines tests/itest-loc"
[env.add]
LC_ALL = "C"
View File
+6
View File
@@ -0,0 +1,6 @@
Language Code Code % Name
Rust - - binary.rs
- - - empty
Rust 4 57.1% hello.rs
Markdown 2 28.6% notes.md
Python 1 14.3% script.py
+4
View File
@@ -0,0 +1,4 @@
bin.name = "eza"
args = "-lh --loc --no-permissions --no-filesize --no-user --no-time tests/itest-loc"
[env.add]
LC_ALL = "C"
Binary file not shown.
View File
+8
View File
@@ -0,0 +1,8 @@
// A greeter, for the lines-of-code tests.
/* Block comments
span lines. */
fn main() {
let url = "https://eza.rocks"; // not a comment
println!("{url}");
}
+3
View File
@@ -0,0 +1,3 @@
# Notes
Some prose.
+2
View File
@@ -0,0 +1,2 @@
# Say hi.
print("hi")