1use std::path::Path;
9
10use file_format::{FileFormat, Kind as FileFormatKind};
11use mime_guess::from_path;
12
13use crate::utils::language::detect_language;
14
15use super::encoding::{has_binary_control_chars, has_decodable_text, looks_like_textual_bytes};
16use super::format_sniff::{
17 ARCHIVE_EXTENSIONS, detect_file_format, format_based_file_type, is_known_binary_format,
18 is_textual_format, is_zip_archive, looks_like_bzip2, looks_like_deb, looks_like_gzip,
19 looks_like_pdf, looks_like_rpm, looks_like_squashfs, looks_like_xz,
20 media_file_type_from_content, media_mime_from_content,
21};
22use super::path::{
23 extension, is_c_like_source, is_java_like_source, is_makefile, is_plain_text, is_source_map,
24 lower_extension, lower_file_name,
25};
26
27const JSON_VALIDATION_MAX_BYTES: usize = 4 * 1024 * 1024;
28const BINARY_EXTENSIONS: &[&str] = &[
29 "pyc", "pyo", "pgm", "pbm", "ppm", "mp3", "mp4", "mpeg", "mpg", "emf",
30];
31
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct FileInfoClassification {
34 pub mime_type: String,
35 pub file_type: String,
36 pub programming_language: Option<String>,
37 pub is_binary: bool,
38 pub is_text: bool,
39 pub is_archive: bool,
40 pub is_media: bool,
41 pub is_source: bool,
42 pub is_script: bool,
43}
44
45pub fn classify_file_info(path: &Path, bytes: &[u8]) -> FileInfoClassification {
46 let detected_format = detect_file_format(bytes);
47 let detected_language = detect_language(path, bytes);
48 let is_binary = detect_is_binary(path, bytes, detected_format, detected_language.as_deref());
49 let is_text = !is_binary;
50 let mime_type = detect_mime_type(path, bytes, detected_format, detected_language.as_deref());
51 let is_archive = detect_is_archive(path, bytes, &mime_type, is_text, detected_format);
52 let is_media = detect_is_media(path, bytes, &mime_type, detected_format);
53 let is_script = detect_is_script(path, bytes, detected_language.as_deref(), is_text);
54 let is_source = detect_is_source(path, detected_language.as_deref(), is_text, is_script);
55 let programming_language = is_source.then(|| detected_language.clone()).flatten();
56 let file_type = detect_file_type(
57 path,
58 bytes,
59 detected_format,
60 &mime_type,
61 programming_language.as_deref(),
62 is_binary,
63 is_text,
64 is_archive,
65 is_media,
66 is_script,
67 );
68
69 FileInfoClassification {
70 mime_type,
71 file_type,
72 programming_language,
73 is_binary,
74 is_text,
75 is_archive,
76 is_media,
77 is_source,
78 is_script,
79 }
80}
81
82pub fn detect_mime_type(
83 path: &Path,
84 bytes: &[u8],
85 detected_format: FileFormat,
86 programming_language: Option<&str>,
87) -> String {
88 if bytes.is_empty() {
89 return "inode/x-empty".to_string();
90 }
91
92 if lower_extension(path).as_deref() == Some("json") {
93 if let Some(is_binary) = json_binary_override(bytes) {
94 if is_binary {
95 return "application/octet-stream".to_string();
96 }
97 if has_valid_json_text(bytes) {
98 return "application/json".to_string();
99 }
100 return "text/plain".to_string();
101 }
102 if has_valid_json_text(bytes) {
103 return "application/json".to_string();
104 }
105 if has_decodable_text(bytes) && looks_like_textual_bytes(bytes) {
106 return "text/plain".to_string();
107 }
108 return "application/octet-stream".to_string();
109 }
110
111 if is_zip_archive(bytes) {
112 return detect_zip_like_mime(path);
113 }
114
115 if looks_like_deb(bytes, path) {
116 return "application/vnd.debian.binary-package".to_string();
117 }
118
119 if looks_like_rpm(bytes, path) {
120 return "application/x-rpm".to_string();
121 }
122
123 let guessed_mime = from_path(path)
124 .first_or_octet_stream()
125 .essence_str()
126 .to_string();
127
128 let mime_type = match detected_format {
129 FileFormat::Empty => "inode/x-empty".to_string(),
130 FileFormat::PlainText => {
131 if guessed_mime == "application/octet-stream" || guessed_mime.starts_with("video/") {
132 "text/plain".to_string()
133 } else {
134 guessed_mime.clone()
135 }
136 }
137 _ => {
138 let detected_mime = detected_format.media_type();
139 if detected_mime == "application/octet-stream"
140 && guessed_mime != "application/octet-stream"
141 {
142 guessed_mime.clone()
143 } else {
144 detected_mime.to_string()
145 }
146 }
147 };
148
149 normalize_mime_type(path, bytes, programming_language, &mime_type)
150}
151
152pub(super) fn normalize_mime_type(
153 path: &Path,
154 bytes: &[u8],
155 programming_language: Option<&str>,
156 mime_type: &str,
157) -> String {
158 if should_prefer_text_mime(path, bytes, programming_language, mime_type) {
159 return "text/plain".to_string();
160 }
161
162 mime_type.to_string()
163}
164
165fn should_prefer_text_mime(
166 path: &Path,
167 bytes: &[u8],
168 programming_language: Option<&str>,
169 mime_type: &str,
170) -> bool {
171 has_decodable_text(bytes)
172 && looks_like_textual_bytes(bytes)
173 && is_textual_source_candidate(path, programming_language)
174 && (mime_type.starts_with("video/") || mime_type == "application/octet-stream")
175}
176
177fn has_valid_json_text(bytes: &[u8]) -> bool {
178 if bytes.len() > JSON_VALIDATION_MAX_BYTES {
179 return false;
180 }
181
182 serde_json::from_slice::<serde_json::Value>(bytes).is_ok()
183 || super::encoding::decode_utf16_json_text(bytes)
184 .and_then(|text| serde_json::from_str::<serde_json::Value>(&text).ok())
185 .is_some()
186}
187
188fn is_wrapped_invalid_json_string_text(bytes: &[u8]) -> bool {
189 !bytes.contains(&0)
190 && !bytes.contains(&0xFF)
191 && bytes.starts_with(b"[\"")
192 && bytes.ends_with(b"\"]")
193 && bytes.len() >= 8
194}
195
196fn json_binary_override(bytes: &[u8]) -> Option<bool> {
197 if has_valid_json_text(bytes) {
198 return Some(false);
199 }
200
201 if bytes.contains(&0) {
202 return Some(true);
203 }
204
205 if bytes.contains(&0xFF) && (bytes.len() <= 5 || bytes.len() > 1024) {
206 return Some(true);
207 }
208
209 if is_wrapped_invalid_json_string_text(bytes) {
210 return Some(false);
211 }
212
213 None
214}
215
216fn detect_is_binary(
217 path: &Path,
218 bytes: &[u8],
219 detected_format: FileFormat,
220 programming_language: Option<&str>,
221) -> bool {
222 if lower_extension(path).as_deref() == Some("json")
223 && let Some(is_binary) = json_binary_override(bytes)
224 {
225 return is_binary;
226 }
227
228 if is_textual_format(detected_format) {
229 return false;
230 }
231
232 if lower_extension(path)
233 .as_deref()
234 .is_some_and(|ext| BINARY_EXTENSIONS.contains(&ext))
235 {
236 return true;
237 }
238
239 if should_treat_binary_bytes_as_text(path, bytes, programming_language) {
240 return false;
241 }
242
243 has_binary_control_chars(bytes)
244 || is_known_binary_format(detected_format)
245 || (matches!(detected_format, FileFormat::ArbitraryBinaryData)
246 && !looks_like_textual_bytes(bytes))
247}
248
249fn should_treat_binary_bytes_as_text(
250 path: &Path,
251 bytes: &[u8],
252 programming_language: Option<&str>,
253) -> bool {
254 has_decodable_text(bytes)
255 && looks_like_textual_bytes(bytes)
256 && (bytes.starts_with(b"#!") || is_textual_source_candidate(path, programming_language))
257}
258
259fn detect_is_archive(
260 path: &Path,
261 bytes: &[u8],
262 mime_type: &str,
263 is_text: bool,
264 detected_format: FileFormat,
265) -> bool {
266 if is_text {
267 return false;
268 }
269
270 lower_extension(path)
271 .as_deref()
272 .is_some_and(|ext| ARCHIVE_EXTENSIONS.contains(&ext))
273 || matches!(
274 detected_format.kind(),
275 FileFormatKind::Archive | FileFormatKind::Compressed | FileFormatKind::Package
276 )
277 || is_zip_archive(bytes)
278 || looks_like_gzip(bytes)
279 || looks_like_bzip2(bytes)
280 || looks_like_xz(bytes)
281 || looks_like_deb(bytes, path)
282 || looks_like_rpm(bytes, path)
283 || looks_like_squashfs(bytes, path)
284 || mime_type.contains("zip")
285 || mime_type.contains("compressed")
286 || mime_type.contains("tar")
287 || mime_type.contains("x-rpm")
288 || mime_type.contains("debian")
289}
290
291fn detect_is_media(
292 path: &Path,
293 bytes: &[u8],
294 mime_type: &str,
295 detected_format: FileFormat,
296) -> bool {
297 media_mime_from_content(bytes).is_some()
298 || matches!(
299 detected_format.kind(),
300 FileFormatKind::Audio | FileFormatKind::Image | FileFormatKind::Video
301 )
302 || mime_type.starts_with("image/")
303 || mime_type.starts_with("audio/")
304 || mime_type.starts_with("video/")
305 || (mime_type == "application/octet-stream"
306 && lower_extension(path).as_deref() == Some("tga")
307 && !has_binary_control_chars(bytes))
308}
309
310fn detect_is_script(
311 path: &Path,
312 bytes: &[u8],
313 programming_language: Option<&str>,
314 is_text: bool,
315) -> bool {
316 if !is_text || is_makefile(path) {
317 return false;
318 }
319
320 bytes.starts_with(b"#!")
321 || lower_extension(path).as_deref().is_some_and(|ext| {
322 matches!(
323 ext,
324 "sh" | "bash" | "zsh" | "fish" | "ksh" | "ps1" | "psm1" | "psd1" | "awk"
325 )
326 })
327 || matches!(
328 programming_language,
329 Some(
330 "Shell"
331 | "Bash"
332 | "Zsh"
333 | "Fish"
334 | "Ksh"
335 | "Python"
336 | "Ruby"
337 | "Perl"
338 | "PHP"
339 | "PowerShell"
340 | "Awk"
341 )
342 )
343}
344
345fn detect_is_source(
346 path: &Path,
347 programming_language: Option<&str>,
348 is_text: bool,
349 is_script: bool,
350) -> bool {
351 if !is_text || is_plain_text(path) || is_makefile(path) || is_source_map(path) {
352 return false;
353 }
354
355 if is_c_like_source(path) || is_java_like_source(path) {
356 return true;
357 }
358
359 programming_language.is_some() || is_script
360}
361
362#[allow(clippy::too_many_arguments)]
367fn detect_file_type(
368 path: &Path,
369 bytes: &[u8],
370 detected_format: FileFormat,
371 mime_type: &str,
372 programming_language: Option<&str>,
373 is_binary: bool,
374 is_text: bool,
375 is_archive: bool,
376 is_media: bool,
377 is_script: bool,
378) -> String {
379 if bytes.is_empty() {
380 return "empty".to_string();
381 }
382
383 if looks_like_pdf(bytes) {
384 return "PDF document".to_string();
385 }
386
387 if let Some(file_type) = media_file_type_from_content(bytes) {
388 return file_type.to_string();
389 }
390
391 if is_archive {
392 return archive_file_type(path, bytes, detected_format);
393 }
394
395 if is_script {
396 return script_file_type(programming_language, bytes);
397 }
398
399 if is_text {
400 if lower_extension(path).as_deref() == Some("json") {
401 if has_valid_json_text(bytes) {
402 return "JSON text data".to_string();
403 }
404 return text_file_type(bytes);
405 }
406 if lower_extension(path).as_deref() == Some("xml") {
407 return "XML text data".to_string();
408 }
409 if matches!(lower_extension(path).as_deref(), Some("yaml" | "yml")) {
410 return "YAML text data".to_string();
411 }
412 if lower_extension(path).as_deref() == Some("toml") {
413 return "TOML text data".to_string();
414 }
415 if matches!(
416 lower_extension(path).as_deref(),
417 Some("ini" | "cfg" | "conf")
418 ) {
419 return "INI text data".to_string();
420 }
421 if matches!(lower_file_name(path).as_str(), ".gitmodules" | ".gitconfig") {
422 return "Git configuration text".to_string();
423 }
424 if matches!(lower_extension(path).as_deref(), Some("md" | "markdown")) {
425 return text_file_type(bytes);
426 }
427 if programming_language.is_some() && !is_media {
428 return source_file_type(programming_language, bytes);
429 }
430 return text_file_type(bytes);
431 }
432
433 if let Some(file_type) = format_based_file_type(detected_format) {
434 return file_type;
435 }
436
437 if is_binary && mime_type == "application/octet-stream" {
438 return "data".to_string();
439 }
440
441 mime_type.to_string()
442}
443
444fn is_textual_source_candidate(path: &Path, programming_language: Option<&str>) -> bool {
445 if matches!(programming_language, Some(language) if is_source_like_language(language)) {
446 return true;
447 }
448
449 if matches!(
450 lower_file_name(path).as_str(),
451 "dockerfile"
452 | "containerfile"
453 | "containerfile.core"
454 | "apkbuild"
455 | "podfile"
456 | "jamfile"
457 | "jamroot"
458 | "meson.build"
459 | "build"
460 | "workspace"
461 | "buck"
462 | "default.nix"
463 | "flake.nix"
464 | "shell.nix"
465 ) {
466 return true;
467 }
468
469 path.extension()
470 .and_then(|ext| ext.to_str())
471 .is_some_and(|ext| {
472 matches!(
473 ext.to_ascii_lowercase().as_str(),
474 "rs" | "py"
475 | "js"
476 | "mjs"
477 | "cjs"
478 | "jsx"
479 | "ts"
480 | "mts"
481 | "cts"
482 | "tsx"
483 | "c"
484 | "cpp"
485 | "cc"
486 | "cxx"
487 | "h"
488 | "hpp"
489 | "m"
490 | "mm"
491 | "s"
492 | "asm"
493 | "java"
494 | "go"
495 | "rb"
496 | "php"
497 | "pl"
498 | "swift"
499 | "sh"
500 | "bash"
501 | "zsh"
502 | "fish"
503 | "ksh"
504 | "ps1"
505 | "psm1"
506 | "psd1"
507 | "awk"
508 | "kt"
509 | "kts"
510 | "dart"
511 | "scala"
512 | "groovy"
513 | "gradle"
514 | "gvy"
515 | "gy"
516 | "gsh"
517 | "cs"
518 | "fs"
519 | "fsx"
520 | "r"
521 | "lua"
522 | "jl"
523 | "ex"
524 | "exs"
525 | "clj"
526 | "cljs"
527 | "cljc"
528 | "hs"
529 | "erl"
530 | "nix"
531 | "zig"
532 | "bzl"
533 | "bazel"
534 | "star"
535 | "sky"
536 | "ml"
537 | "mli"
538 | "tex"
539 )
540 })
541}
542
543fn is_source_like_language(language: &str) -> bool {
544 matches!(
545 language,
546 "Rust"
547 | "Python"
548 | "JavaScript"
549 | "TypeScript"
550 | "JavaScript/TypeScript"
551 | "C"
552 | "C++"
553 | "Objective-C"
554 | "Objective-C++"
555 | "GAS"
556 | "Java"
557 | "Go"
558 | "Ruby"
559 | "PHP"
560 | "Perl"
561 | "Swift"
562 | "Shell"
563 | "PowerShell"
564 | "Awk"
565 | "Kotlin"
566 | "Dart"
567 | "Scala"
568 | "C#"
569 | "F#"
570 | "R"
571 | "Lua"
572 | "Julia"
573 | "Elixir"
574 | "Clojure"
575 | "Haskell"
576 | "Erlang"
577 | "Groovy"
578 | "Nix"
579 | "Zig"
580 | "Starlark"
581 | "OCaml"
582 | "Meson"
583 | "TeX"
584 | "Dockerfile"
585 | "Makefile"
586 | "Jamfile"
587 )
588}
589
590fn detect_zip_like_mime(path: &Path) -> String {
591 match extension(path).map(|ext| ext.to_ascii_lowercase()) {
592 Some(ext) if ext == "apk" => "application/vnd.android.package-archive".to_string(),
593 Some(ext) if matches!(ext.as_str(), "jar" | "war" | "ear") => {
594 "application/java-archive".to_string()
595 }
596 _ => "application/zip".to_string(),
597 }
598}
599
600fn archive_file_type(path: &Path, bytes: &[u8], detected_format: FileFormat) -> String {
601 if looks_like_deb(bytes, path) {
602 "debian binary package (format 2.0)".to_string()
603 } else if looks_like_rpm(bytes, path) {
604 "RPM package".to_string()
605 } else if looks_like_squashfs(bytes, path) {
606 "Squashfs filesystem".to_string()
607 } else if looks_like_gzip(bytes) {
608 "gzip compressed data".to_string()
609 } else if looks_like_bzip2(bytes) {
610 "bzip2 compressed data".to_string()
611 } else if looks_like_xz(bytes) {
612 "XZ compressed data".to_string()
613 } else if is_zip_archive(bytes) {
614 "Zip archive data".to_string()
615 } else if lower_extension(path).as_deref() == Some("gem") {
616 "POSIX tar archive".to_string()
617 } else if let Some(file_type) = format_based_file_type(detected_format) {
618 file_type
619 } else {
620 "archive data".to_string()
621 }
622}
623
624fn script_file_type(programming_language: Option<&str>, bytes: &[u8]) -> String {
625 let suffix = text_executable_label(bytes);
626
627 match programming_language {
628 Some("Python") => format!("python script, {suffix}"),
629 Some("Ruby") => format!("ruby script, {suffix}"),
630 Some("Perl") => format!("perl script, {suffix}"),
631 Some("PHP") => format!("php script, {suffix}"),
632 Some("Shell") => format!("shell script, {suffix}"),
633 Some("Bash") => format!("bash script, {suffix}"),
634 Some("Zsh") => format!("zsh script, {suffix}"),
635 Some("Fish") => format!("fish script, {suffix}"),
636 Some("Ksh") => format!("ksh script, {suffix}"),
637 Some("JavaScript") => format!("javascript script, {suffix}"),
638 Some("TypeScript") => format!("typescript script, {suffix}"),
639 Some("PowerShell") => format!("powershell script, {suffix}"),
640 Some("Awk") => format!("awk script, {suffix}"),
641 _ => format!("script, {suffix}"),
642 }
643}
644
645fn source_file_type(programming_language: Option<&str>, bytes: &[u8]) -> String {
646 let suffix = text_label(bytes);
647 match programming_language {
648 Some("C") => format!("C source, {suffix}"),
649 Some("C++") => format!("C++ source, {suffix}"),
650 Some("Java") => format!("Java source, {suffix}"),
651 Some("C#") => format!("C# source, {suffix}"),
652 Some("F#") => format!("F# source, {suffix}"),
653 Some("Go") => format!("Go source, {suffix}"),
654 Some("Rust") => format!("Rust source, {suffix}"),
655 Some("Starlark") => format!("Starlark source, {suffix}"),
656 Some("CMake") => format!("CMake source, {suffix}"),
657 Some("Meson") => format!("Meson source, {suffix}"),
658 Some("Nix") => format!("Nix source, {suffix}"),
659 Some("Groovy") => format!("Groovy source, {suffix}"),
660 Some("Makefile") => format!("Makefile source, {suffix}"),
661 Some("Dockerfile") => format!("Dockerfile source, {suffix}"),
662 Some("Jamfile") => format!("Jamfile source, {suffix}"),
663 Some("Batchfile") => format!("Batchfile source, {suffix}"),
664 Some(language) => format!("{language} source, {suffix}"),
665 None => text_file_type(bytes),
666 }
667}
668
669fn text_file_type(bytes: &[u8]) -> String {
670 text_label(bytes).to_string()
671}
672
673fn text_label(bytes: &[u8]) -> &'static str {
674 if std::str::from_utf8(bytes).is_ok() {
675 if bytes.contains(&b'\n') {
676 "UTF-8 Unicode text"
677 } else {
678 "UTF-8 Unicode text, with no line terminators"
679 }
680 } else if bytes.contains(&b'\n') {
681 "text"
682 } else {
683 "text, with no line terminators"
684 }
685}
686
687fn text_executable_label(bytes: &[u8]) -> &'static str {
688 if std::str::from_utf8(bytes).is_ok() {
689 if bytes.contains(&b'\n') {
690 "UTF-8 Unicode text executable"
691 } else {
692 "UTF-8 Unicode text executable, with no line terminators"
693 }
694 } else if bytes.contains(&b'\n') {
695 "text executable"
696 } else {
697 "text executable, with no line terminators"
698 }
699}
700
701#[cfg(test)]
702mod tests {
703 use std::path::Path;
704
705 use super::normalize_mime_type;
706
707 #[test]
708 fn test_normalize_mime_type_prefers_text_for_textual_video_guess() {
709 assert_eq!(
710 normalize_mime_type(
711 Path::new("main.ts"),
712 b"export const answer = 42;\n",
713 Some("TypeScript"),
714 "video/mp2t",
715 ),
716 "text/plain"
717 );
718 }
719
720 #[test]
721 fn test_normalize_mime_type_prefers_text_for_octet_stream_source_guess() {
722 assert_eq!(
723 normalize_mime_type(
724 Path::new("main.js"),
725 b"console.log('hello');\n",
726 Some("JavaScript"),
727 "application/octet-stream",
728 ),
729 "text/plain"
730 );
731 }
732
733 #[test]
734 fn test_normalize_mime_type_preserves_binary_video_guess() {
735 assert_eq!(
736 normalize_mime_type(
737 Path::new("main.ts"),
738 &[0, 159, 146, 150, 0, 1, 2, 3],
739 Some("TypeScript"),
740 "video/mp2t",
741 ),
742 "video/mp2t"
743 );
744 }
745
746 #[test]
747 fn test_normalize_mime_type_preserves_short_binary_octet_stream_guess() {
748 assert_eq!(
749 normalize_mime_type(
750 Path::new("main.ts"),
751 &[0, 159, 146, 150],
752 Some("TypeScript"),
753 "application/octet-stream",
754 ),
755 "application/octet-stream"
756 );
757 }
758}