use linguist;
use std::io::Read;
use std::path::Path;
use crate::parsers::FileType;
const PKL_HEAD_BYTES: usize = 4096;
macro_rules! file_extension_map {
(
$(
$ft:ident: $($e:literal),+ $(,)?
);+ $(;)?
) => {
&[
$( $( ( $e, FileType::$ft ) ),+ ),+
]
};
}
const FILE_EXTENSION_MAP: &[(&str, FileType)] = file_extension_map! {
Log: "log";
Json: "json";
Text: "txt";
Markdown: "md", "markdown";
Image: "jpg", "jpeg", "png", "gif", "bmp", "tiff", "tif", "webp", "ico", "svg";
Video: "mp4", "mkv", "avi", "mov", "wmv", "flv", "webm", "m4v", "3gp", "ogv";
Audio: "mp3", "flac", "wav", "m4a", "aac", "ogg", "opus", "wma", "ape", "dsd", "dsf", "aif", "aiff", "aifc";
Csv: "csv", "tsv", "tab", "psv";
Parquet: "parquet";
ArrowIpc: "arrow", "feather", "ipc";
Avro: "avro";
Orc: "orc";
Npy: "npy";
Npz: "npz";
Hdf5: "h5", "hdf5";
NetCdf: "nc", "cdf";
Mtx: "mtx";
Mat: "mat";
Onnx: "onnx";
Gguf: "gguf";
Tflite: "tflite";
Safetensors: "safetensors";
Zarr: "zarr";
Tetration: "tet";
Pickle: "pickle";
Html: "html", "htm";
Docx: "docx";
Xlsx: "xlsx";
Pptx: "pptx";
Sqlite: "db", "sqlite", "sqlite3";
Toml: "toml", "lock";
Ini: "ini", "cfg";
Xml: "xml";
Yaml: "yaml", "yml";
Zip: "zip";
Archive: "tar", "gz", "bz2", "xz", "tgz";
Epub: "epub";
Pdf: "pdf";
};
fn get_file_type_from_extension(extension: &str) -> FileType {
FILE_EXTENSION_MAP
.iter()
.find(|(ext, _)| *ext == extension)
.map_or(FileType::Unknown, |(_, file_type)| *file_type)
}
#[must_use]
pub fn get_extensions_for_file_type(file_type: FileType) -> Vec<&'static str> {
FILE_EXTENSION_MAP
.iter()
.filter_map(|(ext, ft)| if *ft == file_type { Some(*ext) } else { None })
.collect()
}
#[must_use]
pub fn is_codec_for_file_type(codec: &str, file_type: FileType) -> bool {
let codec_lower = codec.to_lowercase();
FILE_EXTENSION_MAP
.iter()
.any(|(ext, ft)| *ft == file_type && codec_lower.contains(ext))
}
#[must_use]
pub fn detect_file_type(path: &str) -> FileType {
let lo = path.to_lowercase();
#[allow(clippy::case_sensitive_file_extension_comparisons)]
if lo.ends_with(".tar.xz")
|| lo.ends_with(".tar.bz2")
|| lo.ends_with(".tar.gz")
|| lo.ends_with(".tgz")
{
return FileType::Archive;
}
let extension = Path::new(path)
.extension()
.and_then(|ext| ext.to_str())
.map(str::to_lowercase)
.unwrap_or_default();
if extension == "pkl" {
let p = Path::new(path);
if p.exists()
&& let Ok(head) = read_pkl_head(p)
&& is_python_pickle_bytes(&head)
{
return FileType::Pickle;
}
}
let file_type = get_file_type_from_extension(&extension);
if file_type == FileType::Unknown {
let p = Path::new(path);
if p.exists() {
let by_ext = linguist::detect_language_by_extension(p)
.ok()
.unwrap_or_default();
if !by_ext.is_empty() {
return FileType::Code;
}
let by_name = linguist::detect_language_by_filename(p)
.ok()
.unwrap_or_default();
if !by_name.is_empty() {
return FileType::Code;
}
}
}
file_type
}
fn read_pkl_head(path: &Path) -> std::io::Result<Vec<u8>> {
let mut file = std::fs::File::open(path)?;
let mut buf = vec![0_u8; PKL_HEAD_BYTES];
let n = file.read(&mut buf)?;
buf.truncate(n);
Ok(buf)
}
fn is_python_pickle_bytes(bytes: &[u8]) -> bool {
if bytes.is_empty() {
return false;
}
if bytes.len() >= 2 && bytes[0] == 0x80 && (2..=5).contains(&bytes[1]) {
return true;
}
let Ok(text) = std::str::from_utf8(bytes) else {
return true;
};
!looks_like_apple_pkl_source(text)
&& (looks_like_text_pickle(text) || text.bytes().any(|b| b == 0))
}
fn looks_like_apple_pkl_source(text: &str) -> bool {
let trimmed = text.trim_start();
trimmed.starts_with("module")
|| trimmed.starts_with("import ")
|| trimmed.starts_with("amends ")
|| trimmed.starts_with("extends ")
|| trimmed.starts_with("local ")
|| trimmed.starts_with("const ")
|| trimmed.starts_with("fixed ")
|| trimmed.starts_with("abstract ")
|| trimmed.starts_with("open ")
|| trimmed.starts_with("class ")
|| trimmed.starts_with("typealias ")
|| trimmed.starts_with('@')
}
fn looks_like_text_pickle(text: &str) -> bool {
matches!(
text.trim_start().as_bytes().first(),
Some(b'(' | b']' | b'}' | b'.')
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn protocol5_header_is_pickle() {
assert!(is_python_pickle_bytes(&[0x80, 0x05, 0x95, 0x0a]));
}
#[test]
fn apple_pkl_source_is_not_pickle() {
let src = "module example\n\nfoo = 1\n";
assert!(!is_python_pickle_bytes(src.as_bytes()));
}
}