use crate::highlight;
use crate::theme;
use ratatui::style::Color;
use std::fs;
use std::io::Read;
use std::path::Path;
const HEAD_BYTES: usize = 8 * 1024;
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Format {
Directory,
Markdown,
Html,
Text,
Sheet,
#[cfg_attr(not(feature = "data"), allow(dead_code))]
Data,
Image,
Svg,
Pdf,
Video,
Docx,
Pptx,
Epub,
Ipynb,
Keynote,
Doc,
Audio,
Archive,
Binary,
}
pub fn classify(ext: &str, is_dir: bool, head: Option<&[u8]>) -> Format {
if is_dir {
return Format::Directory;
}
match ext {
"md" | "markdown" | "mdx" => Format::Markdown,
"html" | "htm" | "xhtml" => Format::Html,
"xlsx" | "xls" | "xlsm" | "xlsb" | "ods" | "csv" | "tsv" => Format::Sheet,
#[cfg(feature = "data")]
"parquet" | "pq" | "jsonl" | "ndjson" | "sqlite" | "sqlite3" | "db" | "db3" | "duckdb"
| "ddb" => Format::Data,
"png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" | "tiff" | "tif" | "ico" => Format::Image,
"pdf" => Format::Pdf,
"mp4" | "mov" | "mkv" | "webm" | "avi" | "m4v" => Format::Video,
"docx" => Format::Docx,
"pptx" => Format::Pptx,
"epub" => Format::Epub,
"ipynb" => Format::Ipynb,
"key" => Format::Keynote,
"doc" | "rtf" | "odt" | "ppt" => Format::Doc,
"mp3" | "wav" | "flac" | "ogg" | "m4a" | "aac" => Format::Audio,
"zip" | "gz" | "tar" | "tgz" | "bz2" | "xz" | "7z" | "rar" | "zst" => Format::Archive,
"svg" => Format::Svg,
e if highlight::is_text_ext(e) => Format::Text,
_ => match head {
Some(h) if looks_textual(h) => Format::Text,
_ => Format::Binary,
},
}
}
fn looks_textual(head: &[u8]) -> bool {
if head.is_empty() || head.contains(&0) {
return false;
}
match std::str::from_utf8(head) {
Ok(_) => true,
Err(e) => e.valid_up_to() >= head.len().saturating_sub(3),
}
}
pub fn classify_path(path: &Path) -> Format {
let is_dir = path.is_dir();
let ext = path
.extension()
.map(|e| e.to_string_lossy().to_lowercase())
.unwrap_or_default();
match classify(&ext, is_dir, None) {
Format::Binary => classify(&ext, is_dir, read_head(path).as_deref()),
other => other,
}
}
fn read_head(path: &Path) -> Option<Vec<u8>> {
let mut f = fs::File::open(path).ok()?;
let mut buf = vec![0u8; HEAD_BYTES];
let n = f.read(&mut buf).ok()?;
buf.truncate(n);
Some(buf)
}
impl Format {
pub fn label(&self) -> &'static str {
match self {
Format::Directory => "Directory",
Format::Markdown => "Markdown",
Format::Html => "HTML",
Format::Text => "Text",
Format::Sheet => "Spreadsheet",
Format::Data => "Data",
Format::Image => "Image",
Format::Svg => "SVG",
Format::Pdf => "PDF",
Format::Video => "Video",
Format::Docx => "Word Document",
Format::Pptx => "Presentation",
Format::Epub => "E-book",
Format::Ipynb => "Notebook",
Format::Keynote => "Keynote",
Format::Doc => "Document",
Format::Audio => "Audio",
Format::Archive => "Archive",
Format::Binary => "File",
}
}
pub fn glyph(&self) -> &'static str {
match self {
Format::Directory => "▸",
Format::Image | Format::Svg => "▦",
Format::Video => "▶",
Format::Audio => "♪",
Format::Pdf => "▤",
Format::Sheet => "▤",
Format::Data => "▨",
Format::Keynote => "▦",
Format::Markdown
| Format::Html
| Format::Docx
| Format::Pptx
| Format::Epub
| Format::Ipynb
| Format::Doc => "▢",
Format::Text => "◇",
Format::Archive => "▣",
Format::Binary => "·",
}
}
pub fn color(&self) -> Color {
match self {
Format::Directory => theme::palette().dir,
Format::Image | Format::Svg | Format::Keynote => theme::palette().image,
Format::Video | Format::Audio => theme::palette().video,
Format::Pdf => theme::palette().pdf,
Format::Sheet | Format::Data => theme::palette().sheet,
Format::Markdown
| Format::Html
| Format::Docx
| Format::Pptx
| Format::Epub
| Format::Ipynb
| Format::Doc => theme::palette().doc,
Format::Text => theme::palette().code,
Format::Archive => theme::palette().archive,
Format::Binary => theme::palette().other,
}
}
pub fn opens(&self) -> bool {
matches!(
self,
Format::Markdown
| Format::Html
| Format::Text
| Format::Sheet
| Format::Data
| Format::Image
| Format::Svg
| Format::Pdf
| Format::Video
| Format::Docx
| Format::Pptx
| Format::Epub
| Format::Ipynb
| Format::Keynote
| Format::Archive
| Format::Binary
)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn by_ext(ext: &str) -> Format {
classify(ext, false, None)
}
#[test]
fn directories_win_over_any_extension() {
assert_eq!(classify("rs", true, None), Format::Directory);
assert_eq!(classify("", true, None), Format::Directory);
}
#[test]
fn tabular_text_is_a_sheet() {
assert_eq!(by_ext("csv"), Format::Sheet);
assert_eq!(by_ext("tsv"), Format::Sheet);
assert_eq!(by_ext("xlsx"), Format::Sheet);
}
#[test]
fn svg_is_its_own_format() {
assert_eq!(by_ext("svg"), Format::Svg);
assert!(by_ext("svg").opens());
}
#[test]
fn office_binaries_are_doc_not_markdown() {
assert_eq!(by_ext("doc"), Format::Doc);
assert_eq!(by_ext("rtf"), Format::Doc);
assert_eq!(by_ext("ppt"), Format::Doc);
}
#[test]
fn pptx_and_keynote_have_their_own_viewers() {
assert_eq!(by_ext("pptx"), Format::Pptx);
assert_eq!(by_ext("key"), Format::Keynote);
assert!(by_ext("pptx").opens());
assert!(by_ext("key").opens());
}
#[test]
fn epub_is_its_own_format() {
assert_eq!(by_ext("epub"), Format::Epub);
assert!(by_ext("epub").opens());
}
#[test]
fn ipynb_is_its_own_format() {
assert_eq!(by_ext("ipynb"), Format::Ipynb);
assert!(by_ext("ipynb").opens());
}
#[cfg(feature = "data")]
#[test]
fn data_files_are_their_own_format() {
for e in [
"parquet", "pq", "jsonl", "ndjson", "sqlite", "sqlite3", "db", "db3", "duckdb", "ddb",
] {
assert_eq!(by_ext(e), Format::Data, "{e} should be Data");
}
assert!(by_ext("parquet").opens());
assert_eq!(by_ext("json"), Format::Text);
}
#[test]
fn source_code_is_text_not_markdown() {
assert_eq!(by_ext("rs"), Format::Text);
assert_eq!(by_ext("py"), Format::Text);
assert_eq!(by_ext("json"), Format::Text);
}
#[test]
fn html_is_its_own_format() {
assert_eq!(by_ext("html"), Format::Html);
assert_eq!(by_ext("htm"), Format::Html);
assert_eq!(by_ext("xhtml"), Format::Html);
assert!(by_ext("html").opens());
}
#[test]
fn known_media_and_doc_extensions() {
assert_eq!(by_ext("md"), Format::Markdown);
assert_eq!(by_ext("docx"), Format::Docx);
assert_eq!(by_ext("png"), Format::Image);
assert_eq!(by_ext("pdf"), Format::Pdf);
assert_eq!(by_ext("mp4"), Format::Video);
assert_eq!(by_ext("mp3"), Format::Audio);
assert_eq!(by_ext("zip"), Format::Archive);
}
#[test]
fn unknown_extension_without_head_is_binary() {
assert_eq!(by_ext("wat"), Format::Binary);
assert_eq!(by_ext(""), Format::Binary);
}
#[test]
fn unknown_extension_with_textual_head_is_text() {
let head = b"hello, this is plain text\n";
assert_eq!(classify("", false, Some(head)), Format::Text);
assert_eq!(classify("wat", false, Some(head)), Format::Text);
}
#[test]
fn unknown_extension_with_nul_head_is_binary() {
let head = b"\x89PNG\x00\x01\x02binary";
assert_eq!(classify("", false, Some(head)), Format::Binary);
assert_eq!(classify("wat", false, Some(head)), Format::Binary);
}
#[test]
fn known_extension_ignores_head() {
assert_eq!(classify("rs", false, Some(b"\x00\x01")), Format::Text);
}
#[test]
fn looks_textual_boundary_split_char() {
let mut head = "text ends with é".as_bytes().to_vec();
head.pop(); assert!(looks_textual(&head));
}
#[test]
fn looks_textual_rejects_nul_and_empty() {
assert!(!looks_textual(b"has a \x00 nul"));
assert!(!looks_textual(b""));
assert!(looks_textual(b"plain ascii"));
}
#[test]
fn opens_matches_the_viewable_set() {
for f in [
Format::Markdown,
Format::Html,
Format::Text,
Format::Sheet,
Format::Image,
Format::Svg,
Format::Pdf,
Format::Video,
Format::Docx,
Format::Pptx,
Format::Epub,
Format::Ipynb,
Format::Keynote,
Format::Archive,
Format::Binary,
] {
assert!(f.opens(), "{f:?} should open");
}
for f in [Format::Directory, Format::Doc, Format::Audio] {
assert!(!f.opens(), "{f:?} should not open");
}
}
}