use std::collections::BTreeMap;
use std::io;
use std::ops::AddAssign;
use std::path::{Path, PathBuf};
use phf::{Map, phf_map};
use rayon::prelude::*;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Language {
pub name: &'static str,
pub line_comments: &'static [&'static str],
pub block_comments: &'static [(&'static str, &'static str)],
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct LocCounts {
pub lines: usize,
pub code: usize,
pub comments: usize,
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 {
#[must_use]
pub fn from_source(source: &str, lang: &Language) -> Self {
let mut counts = Self::default();
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
}
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),
}
}
}
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 {
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;
}
if lang.line_comments.iter().any(|lc| rest.starts_with(lc)) {
has_comment = true;
break;
}
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;
}
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)
}
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()..];
}
}
""
}
#[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()
}
#[derive(Debug, Clone)]
pub struct LangStat {
pub language: &'static Language,
pub files: usize,
pub counts: LocCounts,
pub rep_file: (String, Option<String>),
}
#[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;
}
pub fn languages(&self) -> impl Iterator<Item = &LangStat> {
self.languages.values()
}
#[must_use]
pub fn total(&self) -> LocCounts {
let mut total = LocCounts::default();
for stat in self.languages.values() {
total += stat.counts;
}
total
}
#[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()
}
}
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
}
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();
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() {
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);
}
}
}
#[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)
{
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)
}
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] = &[];
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,
};
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));
}
}