use std::path::{Path, PathBuf};
use anyhow::Result;
use crate::tdg::language_simple::Language;
pub(crate) const TEST_SOURCE_SKIP_REASON: &str =
"test-or-bench file: TDG does not grade test sources";
pub(crate) struct Discovery {
pub(crate) gradable: Vec<PathBuf>,
pub(crate) ungraded: Vec<(PathBuf, String)>,
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct Policy {
grades_markup: bool,
}
impl Policy {
pub(crate) const fn ast() -> Self {
Self {
grades_markup: true,
}
}
pub(crate) const fn heuristic() -> Self {
Self {
grades_markup: false,
}
}
}
pub(crate) fn discover(dir: &Path, policy: Policy) -> Result<Discovery> {
use ignore::WalkBuilder;
let mut gradable = Vec::new();
let mut ungraded = Vec::new();
if !dir.is_dir() {
return Ok(Discovery { gradable, ungraded });
}
for entry in WalkBuilder::new(dir)
.follow_links(false)
.hidden(true)
.parents(true)
.ignore(true)
.git_ignore(true)
.git_global(true)
.git_exclude(true)
.require_git(false)
.filter_entry(|entry| {
if entry.depth() == 0 {
return true;
}
if entry.file_type().is_some_and(|t| t.is_dir()) {
return !is_skipped_directory(entry.path());
}
true
})
.build()
.filter_map(std::result::Result::ok)
{
if !entry.file_type().is_some_and(|t| t.is_file()) {
continue;
}
let path = entry.path();
match classify(path, policy) {
Scope::Gradable => gradable.push(path.to_path_buf()),
Scope::UngradedSource(reason) => ungraded.push((path.to_path_buf(), reason)),
Scope::OutOfPopulation(_) => {}
}
}
gradable.sort();
ungraded.sort();
Ok(Discovery { gradable, ungraded })
}
enum Scope {
Gradable,
UngradedSource(String),
OutOfPopulation(String),
}
pub(crate) fn is_gradable_path(path: &Path, policy: Policy) -> bool {
refusal(path, policy).is_none()
}
pub(crate) fn refusal(path: &Path, policy: Policy) -> Option<String> {
match classify(path, policy) {
Scope::Gradable => None,
Scope::UngradedSource(reason) | Scope::OutOfPopulation(reason) => Some(reason),
}
}
fn classify(path: &Path, policy: Policy) -> Scope {
if is_test_or_bench_source(path) {
return Scope::OutOfPopulation(TEST_SOURCE_SKIP_REASON.to_string());
}
let Some(ext) = path.extension().and_then(|e| e.to_str()) else {
return match shebang_language(path) {
Some(language) => Scope::UngradedSource(format!(
"{language} script: this build grades by file extension and this file has \
none (its interpreter is named on the shebang line), so it is not part \
of the score"
)),
None => Scope::OutOfPopulation(NOT_SOURCE_REASON.to_string()),
};
};
if is_gradable_extension(ext, policy) {
return Scope::Gradable;
}
if let Some(language) = ungradable_source_language(path) {
return Scope::UngradedSource(format!(
"{language} source: this build has no TDG analyzer for .{ext}, so the file \
is not part of the score"
));
}
Scope::OutOfPopulation(format!(
"TDG grades source files and .{ext} is not one, so score and grade are not measured"
))
}
const NOT_SOURCE_REASON: &str =
"TDG grades source files and this file is not one, so score and grade are not measured";
pub(crate) fn is_test_or_bench_source(path: &Path) -> bool {
use std::path::Component;
let resolved = std::path::absolute(path).unwrap_or_else(|_| path.to_path_buf());
let in_test_directory = resolved
.parent()
.into_iter()
.flat_map(Path::components)
.any(|component| {
matches!(component, Component::Normal(name)
if name.to_str().is_some_and(is_test_directory_name))
});
in_test_directory || has_test_file_name(path)
}
fn is_test_directory_name(name: &str) -> bool {
matches!(name, "tests" | "benches" | "examples" | "fuzz")
}
fn has_test_file_name(path: &Path) -> bool {
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
return false;
};
let stem = name.rsplit_once('.').map_or(name, |(stem, _)| stem);
stem == "tests"
|| stem == "test"
|| stem.starts_with("test_")
|| stem.starts_with("tests_")
|| stem.ends_with("_test")
|| stem.ends_with("_tests")
|| stem.contains("_test_")
|| stem.contains("_tests_")
}
fn shebang_language(path: &Path) -> Option<&'static str> {
use std::io::Read;
let mut head = [0u8; 128];
let read = std::fs::File::open(path)
.and_then(|mut f| f.read(&mut head))
.ok()?;
let first_line = String::from_utf8_lossy(&head[..read]);
let first_line = first_line.lines().next()?;
let rest = first_line.trim().strip_prefix("#!")?;
rest.split_whitespace()
.map(|word| word.rsplit('/').next().unwrap_or(word))
.filter(|word| !word.starts_with('-') && *word != "env")
.find_map(interpreter_language)
}
fn interpreter_language(interpreter: &str) -> Option<&'static str> {
let base = interpreter.trim_end_matches(|c: char| c.is_ascii_digit() || c == '.');
Some(match base {
"sh" | "bash" | "dash" | "ash" | "ksh" | "zsh" => "Shell",
"fish" => "Fish shell",
"python" => "Python",
"perl" => "Perl",
"ruby" => "Ruby",
"node" | "deno" | "bun" => "JavaScript",
"lua" => "Lua",
"awk" | "gawk" | "mawk" => "AWK",
"php" => "PHP",
"Rscript" => "R",
"julia" => "Julia",
"tclsh" | "wish" => "Tcl",
"pwsh" | "powershell" => "PowerShell",
"groovy" => "Groovy",
"scala" => "Scala",
"escript" => "Erlang",
"elixir" => "Elixir",
_ => return None,
})
}
fn is_gradable_extension(ext: &str, policy: Policy) -> bool {
let probe = PathBuf::from(format!("probe.{ext}"));
match Language::from_extension(&probe) {
Language::Unknown => false,
Language::Markdown | Language::Yaml => policy.grades_markup,
_ => true,
}
}
fn ungradable_source_language(path: &Path) -> Option<&'static str> {
use crate::services::language_registry::Language as Known;
match Known::from_path(path) {
Known::Unknown => registry_gap_language(path),
known if is_programming_language(known) => Some(known.name()),
_ => None,
}
}
fn is_programming_language(language: crate::services::language_registry::Language) -> bool {
use crate::services::language_registry::Language as L;
match language {
L::Rust
| L::C
| L::Cpp
| L::Go
| L::Zig
| L::Java
| L::Kotlin
| L::Scala
| L::Groovy
| L::Clojure
| L::CSharp
| L::FSharp
| L::VisualBasic
| L::Python
| L::JavaScript
| L::TypeScript
| L::Ruby
| L::PHP
| L::Perl
| L::Lua
| L::Haskell
| L::Elixir
| L::Erlang
| L::OCaml
| L::ReasonML
| L::Elm
| L::PureScript
| L::Lean
| L::Swift
| L::ObjectiveC
| L::Dart
| L::Bash
| L::Zsh
| L::Fish
| L::PowerShell
| L::SQL
| L::Solidity
| L::VHDL
| L::Verilog
| L::R
| L::Julia
| L::Matlab
| L::Assembly
| L::PTX => true,
L::HCL
| L::YAML
| L::TOML
| L::JSON
| L::XML
| L::Markdown
| L::LaTeX
| L::AsciiDoc
| L::Makefile
| L::CMake
| L::Bazel
| L::Gradle
| L::Maven
| L::Unknown => false,
}
}
const REGISTRY_GAP_LANGUAGES: &[(&str, &str)] = &[
("ksh", "Shell"),
("bat", "Batch"),
("cmd", "Batch"),
("csx", "C#"),
("nim", "Nim"),
("cr", "Crystal"),
("d", "D"),
("rmd", "R"),
("tcl", "Tcl"),
("lisp", "Lisp"),
("lsp", "Lisp"),
("el", "Lisp"),
("scm", "Lisp"),
("rkt", "Lisp"),
("pas", "Pascal"),
("pp", "Pascal"),
("adb", "Ada"),
("ads", "Ada"),
("f", "Fortran"),
("f90", "Fortran"),
("f95", "Fortran"),
("for", "Fortran"),
("cob", "COBOL"),
("cbl", "COBOL"),
("vala", "Vala"),
("coffee", "CoffeeScript"),
("vue", "Web component"),
("svelte", "Web component"),
("awk", "AWK"),
("sml", "Standard ML"),
];
fn registry_gap_language(path: &Path) -> Option<&'static str> {
let ext = path.extension().and_then(|e| e.to_str())?.to_lowercase();
REGISTRY_GAP_LANGUAGES
.iter()
.find(|(gap, _)| *gap == ext)
.map(|(_, language)| *language)
}
pub(crate) fn is_skipped_directory(path: &Path) -> bool {
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
return false;
};
if is_test_directory_name(name) {
return true;
}
matches!(
name,
"node_modules"
| "target"
| "build"
| "dist"
| ".git"
| "__pycache__"
| ".pytest_cache"
| "venv"
| ".venv"
| "vendor"
| ".idea"
| ".vscode"
| ".lake"
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_language_with_an_analyzer_is_gradable() {
for ext in ["rs", "py", "go", "ts", "js", "c", "lua", "sql", "scala"] {
for policy in [Policy::ast(), Policy::heuristic()] {
assert!(
is_gradable_extension(ext, policy),
".{ext} has a TDG analyzer"
);
}
}
}
#[test]
fn documentation_and_configuration_are_not_the_source_population() {
for ext in ["json", "toml", "lock", "png", "txt"] {
for policy in [Policy::ast(), Policy::heuristic()] {
assert!(
matches!(
classify(&PathBuf::from(format!("a.{ext}")), policy),
Scope::OutOfPopulation(_)
),
".{ext} is not part of a source average's population"
);
}
}
}
#[test]
fn registry_gap_is_still_a_gap() {
use crate::services::language_registry::Language as Known;
for (ext, language) in REGISTRY_GAP_LANGUAGES {
let probe = PathBuf::from(format!("a.{ext}"));
assert_eq!(
Known::from_path(&probe),
Known::Unknown,
".{ext} ({language}) is in the canonical registry now — delete it from \
REGISTRY_GAP_LANGUAGES so there is one authority again"
);
}
}
#[test]
fn markup_follows_the_policy_of_the_analyzer_that_asked() {
for ext in ["md", "yaml", "yml"] {
assert!(is_gradable_extension(ext, Policy::ast()));
assert!(!is_gradable_extension(ext, Policy::heuristic()));
}
}
#[test]
fn source_this_build_cannot_grade_is_named_not_dropped() {
for ext in ["sh", "php", "cs", "zig"] {
for policy in [Policy::ast(), Policy::heuristic()] {
let scope = classify(&PathBuf::from(format!("a.{ext}")), policy);
let Scope::UngradedSource(reason) = scope else {
panic!(".{ext} is source code and must be reported, not skipped");
};
assert!(reason.contains(&format!(".{ext}")), "{reason}");
}
}
}
}