1use std::borrow::Cow;
43use std::collections::HashMap;
44use std::ffi::OsStr;
45use std::fmt;
46use std::path::Path;
47use std::sync::{Arc, LazyLock};
48
49mod file_rollup_manifest;
50mod file_type_detection;
51
52mod manifest_toml;
55
56mod type_rule_manifest;
61
62#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
64pub enum ContentFamily {
65 Code,
67 Prose,
69 Markup,
71 Data,
73 Binary,
75 Unknown,
77}
78
79impl ContentFamily {
80 pub const fn as_str(self) -> &'static str {
82 match self {
83 Self::Code => "code",
84 Self::Prose => "prose",
85 Self::Markup => "markup",
86 Self::Data => "data",
87 Self::Binary => "binary",
88 Self::Unknown => "unknown",
89 }
90 }
91}
92
93#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
95pub struct FileTypeId(String);
96
97impl FileTypeId {
98 pub fn as_str(&self) -> &str {
100 &self.0
101 }
102
103 pub(crate) fn from_cache(value: String) -> Self {
104 Self(value)
105 }
106}
107
108impl fmt::Display for FileTypeId {
109 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
110 formatter.write_str(&self.0)
111 }
112}
113
114#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
116pub enum DetectionSource {
117 ExactFilename,
119 CompoundExtension,
121 Extension,
123 Shebang,
125 Modeline,
127 AmbiguousContent,
129 FormatSignature,
131 ContentProbe,
133 Unknown,
135}
136
137impl DetectionSource {
138 pub const fn as_str(self) -> &'static str {
140 match self {
141 Self::ExactFilename => "exact_filename",
142 Self::CompoundExtension => "compound_extension",
143 Self::Extension => "extension",
144 Self::Shebang => "shebang",
145 Self::Modeline => "modeline",
146 Self::AmbiguousContent => "ambiguous_content",
147 Self::FormatSignature => "format_signature",
148 Self::ContentProbe => "content_probe",
149 Self::Unknown => "unknown",
150 }
151 }
152}
153
154#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
156pub enum DetectionConfidence {
157 Certain,
159 High,
161 Heuristic,
163}
164
165impl DetectionConfidence {
166 pub const fn as_str(self) -> &'static str {
168 match self {
169 Self::Certain => "certain",
170 Self::High => "high",
171 Self::Heuristic => "heuristic",
172 }
173 }
174}
175
176#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
178pub struct ClassificationFlags {
179 pub generated: bool,
181 pub vendored: bool,
183 pub documentation: bool,
185}
186
187#[derive(Clone, PartialEq, Eq, Debug)]
189pub struct Classification {
190 pub file_type: FileTypeId,
192 pub family: ContentFamily,
194 pub source: DetectionSource,
196 pub confidence: DetectionConfidence,
198 pub flags: ClassificationFlags,
200}
201
202#[derive(Clone, Copy)]
203struct GeneratedRule {
204 id: &'static str,
205 family: ContentFamily,
206 extensions: &'static [&'static str],
207 filenames: &'static [&'static str],
208 shebangs: &'static [&'static str],
209 priority: u16,
210}
211
212include!(concat!(env!("OUT_DIR"), "/file_type_rules.rs"));
213
214fn family_from_name(name: &str) -> Option<ContentFamily> {
220 match name {
221 "code" => Some(ContentFamily::Code),
222 "prose" => Some(ContentFamily::Prose),
223 "markup" => Some(ContentFamily::Markup),
224 "data" => Some(ContentFamily::Data),
225 "binary" => Some(ContentFamily::Binary),
226 "unknown" => Some(ContentFamily::Unknown),
227 _ => None,
228 }
229}
230
231#[derive(Clone, PartialEq, Eq, Debug)]
237struct TypeRule {
238 id: Cow<'static, str>,
239 family: ContentFamily,
240 display_family: Option<Cow<'static, str>>,
241 display_group: Option<Cow<'static, str>>,
242 shebangs: Vec<Cow<'static, str>>,
243 priority: u16,
244}
245
246#[derive(Clone, PartialEq, Eq, Debug)]
248pub struct TypeGroup {
249 id: String,
250 label: String,
251 order: u32,
252}
253
254impl TypeGroup {
255 pub fn id(&self) -> &str {
257 &self.id
258 }
259
260 pub fn label(&self) -> &str {
262 &self.label
263 }
264
265 pub fn order(&self) -> u32 {
267 self.order
268 }
269}
270
271#[derive(Clone, PartialEq, Eq, Debug)]
273pub struct TypeFamily {
274 id: String,
275 label: String,
276 group_id: String,
277 order: u32,
278 extensions: Vec<String>,
279}
280
281impl TypeFamily {
282 pub fn id(&self) -> &str {
284 &self.id
285 }
286
287 pub fn label(&self) -> &str {
289 &self.label
290 }
291
292 pub fn group_id(&self) -> &str {
294 &self.group_id
295 }
296
297 pub fn order(&self) -> u32 {
299 self.order
300 }
301
302 pub fn extensions(&self) -> &[String] {
304 &self.extensions
305 }
306}
307
308#[derive(Clone, PartialEq, Eq, Debug)]
310pub struct NameClassification {
311 logical_extension: Option<String>,
312 canonical_extension: Option<String>,
313 kind_id: Option<String>,
314 family_id: Option<String>,
315 group_id: Option<String>,
316 content_family: ContentFamily,
317}
318
319impl NameClassification {
320 pub fn logical_extension(&self) -> Option<&str> {
322 self.logical_extension.as_deref()
323 }
324
325 pub fn canonical_extension(&self) -> Option<&str> {
327 self.canonical_extension.as_deref()
328 }
329
330 pub fn kind_id(&self) -> Option<&str> {
332 self.kind_id.as_deref()
333 }
334
335 pub fn family_id(&self) -> Option<&str> {
337 self.family_id.as_deref()
338 }
339
340 pub fn group_id(&self) -> Option<&str> {
342 self.group_id.as_deref()
343 }
344
345 pub fn content_family(&self) -> ContentFamily {
347 self.content_family
348 }
349}
350
351#[derive(Clone, PartialEq, Eq, Debug)]
363pub struct TypeRegistry {
364 rules: Vec<TypeRule>,
365 by_filename: HashMap<Cow<'static, str>, u32>,
367 by_extension: HashMap<Cow<'static, str>, u32>,
369 groups: Vec<TypeGroup>,
370 families: Vec<TypeFamily>,
371 registry_revision: Option<u32>,
372 case_insensitive_filenames: bool,
373 fingerprint: u64,
374}
375
376static COMPILED_REGISTRY: LazyLock<Arc<TypeRegistry>> =
380 LazyLock::new(|| Arc::new(TypeRegistry::from_generated()));
381
382impl TypeRegistry {
383 pub fn compiled() -> &'static Self {
385 COMPILED_REGISTRY.as_ref()
386 }
387
388 pub(crate) fn compiled_shared() -> Arc<Self> {
390 Arc::clone(&COMPILED_REGISTRY)
391 }
392
393 pub fn from_manifest(source: &str) -> crate::Result<Self> {
399 let reject = |message: String| crate::Error::InvalidValue {
400 kind: "type rules",
401 value: String::new(),
402 hint: message,
403 };
404 if file_rollup_manifest::looks_like_registry(source) {
405 let parsed = file_rollup_manifest::parse(source).map_err(reject)?;
406 let mut groups: Vec<_> = parsed
407 .groups
408 .iter()
409 .map(|group| TypeGroup {
410 id: group.id.clone(),
411 label: group.label.clone(),
412 order: group.order,
413 })
414 .collect();
415 groups.sort_by(|left, right| {
416 left.order.cmp(&right.order).then_with(|| left.id.cmp(&right.id))
417 });
418 let group_order: HashMap<_, _> =
419 groups.iter().map(|group| (group.id.as_str(), group.order)).collect();
420 let mut families: Vec<_> = parsed
421 .families
422 .iter()
423 .map(|family| TypeFamily {
424 id: family.id.clone(),
425 label: family.label.clone(),
426 group_id: family.group.clone(),
427 order: family.order,
428 extensions: parsed
429 .kinds
430 .iter()
431 .filter(|kind| kind.family == family.id)
432 .flat_map(|kind| kind.extensions.iter().map(|value| format!(".{value}")))
433 .collect(),
434 })
435 .collect();
436 families.sort_by(|left, right| {
437 group_order[&left.group_id.as_str()]
438 .cmp(&group_order[&right.group_id.as_str()])
439 .then_with(|| left.order.cmp(&right.order))
440 .then_with(|| left.id.cmp(&right.id))
441 });
442 let family_groups: HashMap<_, _> = parsed
443 .families
444 .iter()
445 .map(|family| (family.id.as_str(), family.group.as_str()))
446 .collect();
447 let rules = parsed
448 .kinds
449 .iter()
450 .map(|kind| TypeRule {
451 id: Cow::Owned(kind.id.clone()),
452 family: family_from_name(&kind.content_family)
453 .expect("validated content family"),
454 display_family: (!kind.family.is_empty())
455 .then(|| Cow::Owned(kind.family.clone())),
456 display_group: Some(Cow::Owned(if kind.group.is_empty() {
457 family_groups[&kind.family.as_str()].to_string()
458 } else {
459 kind.group.clone()
460 })),
461 shebangs: kind.shebangs.iter().cloned().map(Cow::Owned).collect(),
462 priority: kind.priority,
463 })
464 .collect();
465 let mut registry = Self::indexed(
466 rules,
467 parsed.kinds.iter().map(|kind| kind.filenames.iter().cloned().map(Cow::Owned)),
468 parsed.kinds.iter().map(|kind| kind.extensions.iter().cloned().map(Cow::Owned)),
469 file_rollup_manifest::fingerprint(&parsed),
470 );
471 registry.groups = groups;
472 registry.families = families;
473 registry.registry_revision = Some(parsed.revision);
474 registry.case_insensitive_filenames = true;
475 return Ok(registry);
476 }
477 let parsed = type_rule_manifest::parse_manifest(source).map_err(reject)?;
478 type_rule_manifest::validate_manifest(&parsed).map_err(reject)?;
479 let rules = parsed
480 .iter()
481 .map(|rule| TypeRule {
482 id: Cow::Owned(rule.id.clone()),
483 family: family_from_name(&rule.family).expect("validated family"),
484 display_family: None,
485 display_group: None,
486 shebangs: rule.shebangs.iter().cloned().map(Cow::Owned).collect(),
487 priority: rule.priority,
488 })
489 .collect();
490 Ok(Self::indexed(
491 rules,
492 parsed.iter().map(|rule| rule.filenames.iter().cloned().map(Cow::Owned)),
493 parsed.iter().map(|rule| rule.extensions.iter().cloned().map(Cow::Owned)),
494 type_rule_manifest::manifest_fingerprint(&parsed),
495 ))
496 }
497
498 fn from_generated() -> Self {
499 let rules = GENERATED_RULES
500 .iter()
501 .map(|rule| TypeRule {
502 id: Cow::Borrowed(rule.id),
503 family: rule.family,
504 display_family: None,
505 display_group: None,
506 shebangs: rule.shebangs.iter().copied().map(Cow::Borrowed).collect(),
507 priority: rule.priority,
508 })
509 .collect();
510 Self::indexed(
511 rules,
512 GENERATED_RULES.iter().map(|rule| rule.filenames.iter().copied().map(Cow::Borrowed)),
513 GENERATED_RULES.iter().map(|rule| rule.extensions.iter().copied().map(Cow::Borrowed)),
514 TYPE_RULE_FINGERPRINT,
515 )
516 }
517
518 fn indexed(
520 rules: Vec<TypeRule>,
521 filenames: impl Iterator<Item = impl Iterator<Item = Cow<'static, str>>>,
522 extensions: impl Iterator<Item = impl Iterator<Item = Cow<'static, str>>>,
523 fingerprint: u64,
524 ) -> Self {
525 let by_filename = index_keys(filenames);
526 let by_extension = index_keys(extensions);
527 Self {
528 rules,
529 by_filename,
530 by_extension,
531 groups: Vec::new(),
532 families: Vec::new(),
533 registry_revision: None,
534 case_insensitive_filenames: false,
535 fingerprint,
536 }
537 }
538
539 pub fn fingerprint(&self) -> u64 {
541 self.fingerprint
542 }
543
544 pub fn registry_revision(&self) -> Option<u32> {
546 self.registry_revision
547 }
548
549 pub fn groups(&self) -> impl Iterator<Item = &TypeGroup> {
551 self.groups.iter()
552 }
553
554 pub fn families(&self) -> impl Iterator<Item = &TypeFamily> {
556 self.families.iter()
557 }
558
559 pub fn family(&self, id: &str) -> Option<&TypeFamily> {
561 self.families.iter().find(|family| family.id == id)
562 }
563
564 pub fn rule_count(&self) -> usize {
566 self.rules.len()
567 }
568
569 pub fn filename_count(&self) -> usize {
571 self.by_filename.len()
572 }
573
574 pub(crate) fn exact_filenames(&self) -> impl Iterator<Item = &str> {
581 self.by_filename.keys().map(AsRef::as_ref)
582 }
583
584 pub fn extension_count(&self) -> usize {
586 self.by_extension.len()
587 }
588
589 pub fn canonical_ext(&self, name: &OsStr) -> Option<String> {
601 if name.to_str().and_then(|name| self.by_filename(name)).is_some() {
602 return None;
603 }
604 self.extension_match(name).map(|(extension, _)| extension.as_str().to_string())
605 }
606
607 fn extension_match(&self, name: &OsStr) -> Option<(InlineExtension, &TypeRule)> {
613 let logical = InlineExtension::logical(name)?;
614 let key =
615 logical.as_str().strip_prefix('.').expect("a logical extension starts with a dot");
616 if let Some(rule) = self.by_extension(key) {
617 return Some((logical, rule));
618 }
619 let (_, suffix) = key.rsplit_once('.')?;
620 self.by_extension(suffix).map(|rule| (InlineExtension::dotted(suffix), rule))
621 }
622
623 pub fn type_ids(&self) -> impl Iterator<Item = &str> {
625 self.rules.iter().map(|rule| rule.id.as_ref())
626 }
627
628 pub fn classify_name(&self, name: &OsStr) -> NameClassification {
630 let logical_extension = logical_ext(name);
631 let filename_rule = name.to_str().and_then(|name| self.by_filename(name));
632 let extension_match = filename_rule.is_none().then(|| self.extension_match(name));
633 let (canonical_extension, rule) = match (filename_rule, extension_match.flatten()) {
634 (Some(rule), _) => (None, Some(rule)),
635 (None, Some((extension, rule))) => (Some(extension.as_str().to_string()), Some(rule)),
636 (None, None) => (None, None),
637 };
638 let fallback_group = self
639 .registry_revision
640 .is_some()
641 .then(|| self.groups.iter().find(|group| group.id == "other"))
642 .flatten()
643 .map(|group| group.id.clone());
644 NameClassification {
645 logical_extension,
646 canonical_extension,
647 kind_id: rule.map(|rule| rule.id.to_string()),
648 family_id: rule.and_then(|rule| rule.display_family.as_ref().map(ToString::to_string)),
649 group_id: rule
650 .and_then(|rule| rule.display_group.as_ref().map(ToString::to_string))
651 .or(fallback_group),
652 content_family: rule.map_or(ContentFamily::Unknown, |rule| rule.family),
653 }
654 }
655
656 fn by_filename(&self, name: &str) -> Option<&TypeRule> {
657 self.by_filename
658 .get(name)
659 .or_else(|| {
660 self.case_insensitive_filenames
661 .then(|| name.to_ascii_lowercase())
662 .and_then(|name| self.by_filename.get(name.as_str()))
663 })
664 .map(|index| &self.rules[*index as usize])
665 }
666
667 fn by_extension(&self, key: &str) -> Option<&TypeRule> {
668 self.by_extension.get(key).map(|index| &self.rules[*index as usize])
669 }
670
671 fn by_id(&self, id: &str) -> Option<&TypeRule> {
672 self.rules.iter().find(|rule| rule.id == id)
673 }
674}
675
676fn index_keys(
678 keys: impl Iterator<Item = impl Iterator<Item = Cow<'static, str>>>,
679) -> HashMap<Cow<'static, str>, u32> {
680 let mut table: HashMap<Cow<'static, str>, u32> = HashMap::new();
681 for (position, rule_keys) in keys.enumerate() {
682 let index = u32::try_from(position).expect("a manifest holds fewer than 4 billion rules");
683 for key in rule_keys {
684 let previous = table.insert(key, index);
685 debug_assert!(previous.is_none(), "validated registry keys are unique");
686 }
687 }
688 table
689}
690
691pub const fn type_rule_fingerprint() -> u64 {
693 TYPE_RULE_FINGERPRINT
694}
695
696pub(crate) fn human_language_name(id: &str) -> &str {
701 match id {
702 "rust" => "Rust",
703 "python" => "Python",
704 "javascript" => "JavaScript",
705 "typescript" => "TypeScript",
706 "go" => "Go",
707 "c" => "C",
708 "cpp" => "C++",
709 "csharp" => "C#",
710 "java" => "Java",
711 "kotlin" => "Kotlin",
712 "swift" => "Swift",
713 "ruby" => "Ruby",
714 "php" => "PHP",
715 "shell" => "Shell",
716 "powershell" => "PowerShell",
717 "lua" => "Lua",
718 "perl" => "Perl",
719 "r" => "R",
720 "dart" => "Dart",
721 "scala" => "Scala",
722 "haskell" => "Haskell",
723 "elixir" => "Elixir",
724 "erlang" => "Erlang",
725 "clojure" => "Clojure",
726 "fsharp" => "F#",
727 "ocaml" => "OCaml",
728 "objective-c" => "Objective-C",
729 "julia" => "Julia",
730 "zig" => "Zig",
731 "nim" => "Nim",
732 "solidity" => "Solidity",
733 "assembly" => "Assembly",
734 "sql" => "SQL",
735 "make" => "Make",
736 "dockerfile" => "Dockerfile",
737 "cmake" => "CMake",
738 "protobuf" => "Protocol Buffers",
739 "terraform" => "Terraform",
740 "nix" => "Nix",
741 "css" => "CSS",
742 _ => id,
743 }
744}
745
746pub fn classify_path(path: &Path) -> Classification {
748 classify_path_with_prefix(path, None)
749}
750
751pub fn classify_path_with_prefix(path: &Path, prefix: Option<&[u8]>) -> Classification {
753 classify_with(TypeRegistry::compiled(), path, prefix)
754}
755
756pub fn classify_with(
763 registry: &TypeRegistry,
764 path: &Path,
765 prefix: Option<&[u8]>,
766) -> Classification {
767 let name = path.file_name().unwrap_or_else(|| OsStr::new(""));
768 if let Some(rule) = name.to_str().and_then(|name| registry.by_filename(name)) {
771 return with_flags(
772 path,
773 prefix,
774 classified(rule, DetectionSource::ExactFilename, DetectionConfidence::Certain),
775 );
776 }
777
778 if let Some((extension, rule)) = registry.extension_match(name) {
779 let key =
780 extension.as_str().strip_prefix('.').expect("derived extensions start with a dot");
781 let source = if key.contains('.') {
782 DetectionSource::CompoundExtension
783 } else {
784 DetectionSource::Extension
785 };
786 let classification = if key == "h" {
787 prefix
788 .and_then(file_type_detection::resolve_c_header)
789 .and_then(|id| registry.by_id(id))
790 .map_or_else(
791 || classified(rule, source, DetectionConfidence::Certain),
792 |cpp| {
793 classified(
794 cpp,
795 DetectionSource::AmbiguousContent,
796 DetectionConfidence::High,
797 )
798 },
799 )
800 } else {
801 classified(rule, source, DetectionConfidence::Certain)
802 };
803 return with_flags(path, prefix, classification);
804 }
805
806 let extension = derive_ext(name);
810 let unknown = || Classification {
811 file_type: FileTypeId(
812 extension
813 .as_deref()
814 .map_or_else(|| "unknown".to_string(), |ext| format!("unknown:{ext}")),
815 ),
816 family: ContentFamily::Unknown,
817 source: DetectionSource::Unknown,
818 confidence: DetectionConfidence::Heuristic,
819 flags: ClassificationFlags::default(),
820 };
821 let Some(prefix) = prefix else {
822 return with_flags(path, None, unknown());
823 };
824 let probed = file_type_detection::probe_unresolved(prefix);
825 match probed {
826 Some(file_type_detection::PrefixMatch::UnknownBinary) => {
827 return with_flags(
828 path,
829 Some(prefix),
830 Classification {
831 family: ContentFamily::Binary,
832 source: DetectionSource::ContentProbe,
833 ..unknown()
834 },
835 );
836 }
837 Some(file_type_detection::PrefixMatch::Rule(id, source))
838 if registry.by_id(id).is_some_and(|rule| rule.family == ContentFamily::Binary) =>
839 {
840 let rule = registry.by_id(id).expect("guard established a registry rule");
841 return with_flags(
842 path,
843 Some(prefix),
844 classified(rule, source, DetectionConfidence::High),
845 );
846 }
847 _ => {}
848 }
849 if let Some(interpreter) = file_type_detection::shebang_interpreter(prefix) {
850 if let Some(rule) = registry
851 .rules
852 .iter()
853 .filter(|rule| rule.shebangs.iter().any(|shebang| shebang == interpreter))
854 .max_by_key(|rule| rule.priority)
855 {
856 return with_flags(
857 path,
858 Some(prefix),
859 classified(rule, DetectionSource::Shebang, DetectionConfidence::High),
860 );
861 }
862 }
863 let classification = match probed {
864 Some(file_type_detection::PrefixMatch::Rule(id, source)) => registry
865 .by_id(id)
866 .map_or_else(unknown, |rule| classified(rule, source, DetectionConfidence::High)),
867 Some(file_type_detection::PrefixMatch::UnknownBinary) => {
868 unreachable!("returned above")
869 }
870 None => unknown(),
871 };
872 with_flags(path, Some(prefix), classification)
873}
874
875fn classified(
876 rule: &TypeRule,
877 source: DetectionSource,
878 confidence: DetectionConfidence,
879) -> Classification {
880 Classification {
881 file_type: FileTypeId(rule.id.to_string()),
882 family: rule.family,
883 source,
884 confidence,
885 flags: ClassificationFlags::default(),
886 }
887}
888
889fn with_flags(
890 path: &Path,
891 prefix: Option<&[u8]>,
892 mut classification: Classification,
893) -> Classification {
894 classification.flags = file_type_detection::flags(path, prefix);
895 classification
896}
897
898pub fn logical_ext(name: &OsStr) -> Option<String> {
918 logical_ext_native(name)
919}
920
921pub fn derive_ext(name: &OsStr) -> Option<String> {
952 derive_ext_native(name)
953}
954
955pub fn ext_bucket(name: &OsStr) -> String {
980 derive_ext(name).unwrap_or_else(|| NO_EXTENSION.to_string())
981}
982
983pub const NO_EXTENSION: &str = "(none)";
988
989#[cfg(unix)]
990fn derive_ext_native(name: &OsStr) -> Option<String> {
991 use std::os::unix::ffi::OsStrExt;
992
993 derive_ext_units(name.as_bytes(), b'.', |unit| unit.to_ascii_lowercase())
994 .and_then(|units| String::from_utf8(units).ok())
995}
996
997#[cfg(windows)]
998fn derive_ext_native(name: &OsStr) -> Option<String> {
999 use std::os::windows::ffi::OsStrExt;
1000
1001 let units: Vec<u16> = name.encode_wide().collect();
1002 derive_ext_units(&units, u16::from(b'.'), |unit| {
1003 match u8::try_from(unit) {
1007 Ok(byte) => u16::from(byte.to_ascii_lowercase()),
1008 Err(_) => unit,
1009 }
1010 })
1011 .and_then(|extension| String::from_utf16(&extension).ok())
1012}
1013
1014#[cfg(not(any(unix, windows)))]
1015fn derive_ext_native(name: &OsStr) -> Option<String> {
1016 derive_ext_str(name.to_str()?)
1017}
1018
1019fn derive_ext_units<T: Copy + Eq + From<u8>>(
1020 name: &[T],
1021 dot: T,
1022 lowercase: impl Fn(T) -> T,
1023) -> Option<Vec<T>> {
1024 let searchable = if name.first() == Some(&dot) { &name[1..] } else { name };
1025 let dot_index = searchable.iter().rposition(|unit| *unit == dot)?;
1026 let (stem, last) = searchable.split_at(dot_index);
1027 if last.len() <= 1 {
1028 return None;
1029 }
1030
1031 let mut extension = Vec::new();
1032 if let Some(inner_dot) = stem.iter().rposition(|unit| *unit == dot) {
1033 let inner = &stem[inner_dot..];
1034 let tar = [
1035 dot,
1036 lowercase_ascii_unit(b't', &lowercase),
1037 lowercase_ascii_unit(b'a', &lowercase),
1038 lowercase_ascii_unit(b'r', &lowercase),
1039 ];
1040 if inner.len() == tar.len() && inner.iter().copied().map(&lowercase).eq(tar) {
1041 extension.extend(inner.iter().copied().map(&lowercase));
1042 }
1043 }
1044 extension.extend(last.iter().copied().map(lowercase));
1045 Some(extension)
1046}
1047
1048fn lowercase_ascii_unit<T: Copy + From<u8>>(byte: u8, lowercase: &impl Fn(T) -> T) -> T {
1049 lowercase(T::from(byte))
1050}
1051
1052#[cfg(not(any(unix, windows)))]
1053fn derive_ext_str(name: &str) -> Option<String> {
1054 let searchable = name.strip_prefix('.').unwrap_or(name);
1056 let dot = searchable.rfind('.')?;
1057 let (stem, last) = searchable.split_at(dot);
1058 if last.len() <= 1 {
1059 return None;
1061 }
1062
1063 if let Some(inner_dot) = stem.rfind('.') {
1064 if stem[inner_dot..].eq_ignore_ascii_case(".tar") {
1065 return Some(format!(".tar{}", last.to_ascii_lowercase()));
1066 }
1067 }
1068
1069 Some(last.to_ascii_lowercase())
1070}
1071
1072#[cfg(unix)]
1073fn logical_ext_native(name: &OsStr) -> Option<String> {
1074 use std::os::unix::ffi::OsStrExt;
1075
1076 logical_ext_units(name.as_bytes(), b'.', |unit| unit.to_ascii_lowercase())
1077 .and_then(|units| String::from_utf8(units).ok())
1078}
1079
1080#[cfg(windows)]
1081fn logical_ext_native(name: &OsStr) -> Option<String> {
1082 use std::os::windows::ffi::OsStrExt;
1083
1084 let units: Vec<u16> = name.encode_wide().collect();
1085 logical_ext_units(&units, u16::from(b'.'), |unit| {
1086 match u8::try_from(unit) {
1090 Ok(byte) => u16::from(byte.to_ascii_lowercase()),
1091 Err(_) => unit,
1092 }
1093 })
1094 .and_then(|extension| String::from_utf16(&extension).ok())
1095}
1096
1097#[cfg(not(any(unix, windows)))]
1098fn logical_ext_native(name: &OsStr) -> Option<String> {
1099 let units: Vec<char> = name.to_str()?.chars().collect();
1100 logical_ext_units(&units, '.', |unit| unit.to_ascii_lowercase())
1101 .map(|extension| extension.into_iter().collect())
1102}
1103
1104const MAX_LOGICAL_EXTENSION_COMPONENT: usize = 12;
1105
1106fn eligible_extension_component<T: Copy + Eq + From<u8>>(component: &[T]) -> bool {
1107 !component.is_empty()
1108 && component.len() <= MAX_LOGICAL_EXTENSION_COMPONENT
1109 && component.iter().all(|unit| {
1110 (b'0'..=b'9').chain(b'a'..=b'z').chain(b'A'..=b'Z').any(|byte| *unit == T::from(byte))
1111 })
1112}
1113
1114fn logical_ext_components<T: Copy + Eq + From<u8>>(
1117 name: &[T],
1118 dot: T,
1119) -> Option<(Option<&[T]>, &[T])> {
1120 let searchable = if name.first() == Some(&dot) { &name[1..] } else { name };
1121 let dot_index = searchable.iter().rposition(|unit| *unit == dot)?;
1122 let (stem, last) = searchable.split_at(dot_index);
1123 let last = &last[1..];
1124 if !eligible_extension_component(last) {
1125 return None;
1126 }
1127 let inner = stem
1128 .iter()
1129 .rposition(|unit| *unit == dot)
1130 .map(|inner_dot| &stem[inner_dot + 1..])
1131 .filter(|inner| eligible_extension_component(inner));
1132 Some((inner, last))
1133}
1134
1135fn logical_ext_units<T: Copy + Eq + From<u8>>(
1136 name: &[T],
1137 dot: T,
1138 lowercase: impl Fn(T) -> T,
1139) -> Option<Vec<T>> {
1140 let (inner, last) = logical_ext_components(name, dot)?;
1141 let mut extension = Vec::new();
1142 for component in inner.into_iter().chain([last]) {
1143 extension.push(dot);
1144 extension.extend(component.iter().copied().map(&lowercase));
1145 }
1146 Some(extension)
1147}
1148
1149const MAX_LOGICAL_EXTENSION_BYTES: usize = 2 * (MAX_LOGICAL_EXTENSION_COMPONENT + 1);
1152
1153#[derive(Clone, Copy)]
1163struct InlineExtension {
1164 bytes: [u8; MAX_LOGICAL_EXTENSION_BYTES],
1165 len: usize,
1166}
1167
1168impl InlineExtension {
1169 const EMPTY: Self = Self { bytes: [0; MAX_LOGICAL_EXTENSION_BYTES], len: 0 };
1170
1171 fn logical(name: &OsStr) -> Option<Self> {
1173 #[cfg(unix)]
1174 let units = std::os::unix::ffi::OsStrExt::as_bytes(name);
1175 #[cfg(not(unix))]
1179 let Some(units) = name.to_str().map(str::as_bytes) else {
1180 return logical_ext(name).map(|extension| Self::EMPTY.with(extension.bytes()));
1181 };
1182 let (inner, last) = logical_ext_components(units, b'.')?;
1183 let mut extension = Self::EMPTY;
1184 for component in inner.into_iter().chain([last]) {
1185 extension = extension.with(*b".").with(component.iter().map(u8::to_ascii_lowercase));
1186 }
1187 Some(extension)
1188 }
1189
1190 fn dotted(component: &str) -> Self {
1192 Self::EMPTY.with(*b".").with(component.bytes())
1193 }
1194
1195 fn with(mut self, bytes: impl IntoIterator<Item = u8>) -> Self {
1196 for byte in bytes {
1197 self.bytes[self.len] = byte;
1198 self.len += 1;
1199 }
1200 self
1201 }
1202
1203 fn as_str(&self) -> &str {
1204 std::str::from_utf8(&self.bytes[..self.len]).expect("an eligible extension is ASCII")
1205 }
1206}
1207
1208#[cfg(test)]
1209mod tests {
1210 use super::type_rule_manifest::{MANIFEST_FAMILIES, ManifestRule, parse_manifest};
1211 use super::{
1212 ContentFamily, DetectionConfidence, DetectionSource, InlineExtension, NO_EXTENSION,
1213 TypeRegistry, classify_path, classify_path_with_prefix, classify_with, derive_ext,
1214 ext_bucket, family_from_name, logical_ext, type_rule_fingerprint,
1215 };
1216 use super::{GENERATED_RULES, human_language_name};
1217 use std::ffi::OsStr;
1218 use std::path::Path;
1219
1220 const DEFAULT_MANIFEST: &str = include_str!("../rules/file-types.toml");
1222
1223 fn default_manifest_rules() -> Vec<ManifestRule> {
1224 parse_manifest(DEFAULT_MANIFEST).expect("the repository's own manifest parses")
1225 }
1226
1227 #[test]
1228 fn every_validated_manifest_family_maps_to_an_engine_family() {
1229 for family in MANIFEST_FAMILIES {
1230 assert!(
1231 family_from_name(family).is_some(),
1232 "manifest validation admits {family:?}, but registry construction cannot map it"
1233 );
1234 }
1235 }
1236
1237 fn registry_indexes_every_claim(registry: &TypeRegistry, rules: &[ManifestRule], label: &str) {
1239 fn filenames(rule: &ManifestRule) -> &Vec<String> {
1240 &rule.filenames
1241 }
1242 fn extensions(rule: &ManifestRule) -> &Vec<String> {
1243 &rule.extensions
1244 }
1245 type KeysOf = fn(&ManifestRule) -> &Vec<String>;
1247 let tiers: [(KeysOf, _); 2] =
1248 [(filenames, ®istry.by_filename), (extensions, ®istry.by_extension)];
1249 for (keys_of, table) in tiers {
1250 let mut checked = 0;
1251 for expected in rules {
1252 for key in keys_of(expected) {
1253 let indexed = ®istry.rules[table[key.as_str()] as usize];
1254 assert_eq!(
1255 indexed.id, expected.id,
1256 "{label}: key {key:?} resolves to {:?}, expected {:?}",
1257 indexed.id, expected.id
1258 );
1259 checked += 1;
1260 }
1261 }
1262 assert!(checked > 0, "{label}: the rules produced no keys to check");
1263 }
1264 }
1265
1266 #[test]
1267 fn compiled_and_runtime_registries_index_every_manifest_claim() {
1268 let rules = default_manifest_rules();
1269 registry_indexes_every_claim(TypeRegistry::compiled(), &rules, "compiled");
1270 let parsed = TypeRegistry::from_manifest(DEFAULT_MANIFEST).expect("parses at run time");
1271 registry_indexes_every_claim(&parsed, &rules, "runtime");
1272 }
1273
1274 #[test]
1281 fn the_runtime_parsed_default_matches_the_compiled_one() {
1282 let compiled = TypeRegistry::compiled();
1283 let parsed = TypeRegistry::from_manifest(DEFAULT_MANIFEST).expect("parses at run time");
1284
1285 assert_eq!(parsed.fingerprint(), compiled.fingerprint(), "same text, same identity");
1286 assert_eq!(parsed.rule_count(), compiled.rule_count());
1287 assert_eq!(parsed.extension_count(), compiled.extension_count());
1288 assert_eq!(parsed.filename_count(), compiled.filename_count());
1289 assert_eq!(parsed.type_ids().collect::<Vec<_>>(), compiled.type_ids().collect::<Vec<_>>());
1290
1291 let mut probes: Vec<String> = Vec::new();
1292 for rule in default_manifest_rules() {
1293 probes.extend(rule.filenames.iter().cloned());
1294 probes.extend(rule.extensions.iter().map(|ext| format!("probe.{ext}")));
1295 }
1296 assert!(probes.len() > 100, "the manifest should offer a wide key set");
1297 for probe in probes {
1298 let path = Path::new(&probe);
1299 assert_eq!(
1300 classify_with(&parsed, path, None),
1301 classify_with(compiled, path, None),
1302 "{probe} classifies differently under the runtime-parsed default"
1303 );
1304 }
1305 }
1306
1307 #[test]
1309 fn a_registry_rejects_manifests_that_would_classify_ambiguously() {
1310 for (manifest, expected) in [
1311 ("", "at least one [[kind]] rule is required"),
1312 (
1313 "[[kind]]\nid = \"a\"\nfamily = \"code\"\n[[kind]]\nid = \"a\"\nfamily = \"code\"\n",
1314 "duplicate rule id",
1315 ),
1316 ("[[kind]]\nid = \"a\"\nfamily = \"pictures\"\n", "invalid family"),
1317 (
1318 "[[kind]]\nid = \"a\"\nfamily = \"code\"\nextensions = [\"q\"]\n[[kind]]\nid = \"b\"\nfamily = \"data\"\nextensions = [\"q\"]\n",
1319 "is assigned to both",
1320 ),
1321 ("[[kind]]\nid = \"A\"\nfamily = \"code\"\n", "invalid rule id"),
1322 (
1323 "[[kind]]\nid = \"a\"\nfamily = \"code\"\nextensions = [\".q\"]\n",
1324 "invalid extension",
1325 ),
1326 ("id = \"a\"\n", "field appears before [[kind]]"),
1327 ("[[kind]]\nid = a\n", "expected a quoted string"),
1328 ("[[kind]]\nid = \"a\"\nid = \"b\"\nfamily = \"code\"\n", "duplicate field \"id\""),
1329 ] {
1330 let error =
1331 TypeRegistry::from_manifest(manifest).expect_err("this manifest must be rejected");
1332 let message = error.to_string();
1333 assert!(message.contains(expected), "{message:?} should mention {expected:?}");
1334 }
1335 }
1336
1337 #[test]
1339 fn supplied_rules_replace_the_compiled_taxonomy() {
1340 let registry = TypeRegistry::from_manifest(
1341 "[[kind]]\nid = \"notes\"\nfamily = \"prose\"\nextensions = [\"rs\"]\n",
1342 )
1343 .expect("a minimal manifest");
1344
1345 let ours = classify_with(®istry, Path::new("main.rs"), None);
1346 assert_eq!(ours.file_type.as_str(), "notes");
1347 assert_eq!(ours.family, ContentFamily::Prose);
1348
1349 assert_eq!(classify_path(Path::new("main.rs")).file_type.as_str(), "rust");
1351
1352 assert_eq!(
1355 classify_with(®istry, Path::new("a.py"), None).source,
1356 DetectionSource::Unknown
1357 );
1358 assert_ne!(
1359 registry.fingerprint(),
1360 type_rule_fingerprint(),
1361 "different rules must invalidate a snapshot taken under the others"
1362 );
1363 }
1364
1365 #[test]
1366 fn file_rollup_v3_registry_supplies_display_taxonomy_and_content_classification() {
1367 let registry = TypeRegistry::from_manifest(
1368 r#"
1369schema_version = 3
1370registry_revision = 7
1371max_extension_components = 2
1372
1373[[group]]
1374id = "code"
1375label = "Code"
1376order = 10
1377
1378[[group]]
1379id = "other"
1380label = "Other"
1381order = 20
1382
1383[[family]]
1384id = "javascript"
1385label = "JavaScript"
1386group = "code"
1387order = 100
1388hue = 102.0
1389
1390[[kind]]
1391id = "javascript"
1392family = "javascript"
1393content_family = "code"
1394extensions = ["js", "js.map"]
1395filenames = []
1396shebangs = ["node"]
1397priority = 100
1398
1399[[kind]]
1400id = "make"
1401group = "other"
1402content_family = "code"
1403extensions = []
1404filenames = ["makefile"]
1405shebangs = []
1406priority = 100
1407"#,
1408 )
1409 .expect("File Rollup v3 registry parses");
1410
1411 assert_eq!(registry.registry_revision(), Some(7));
1412 assert_eq!(
1413 registry.groups().map(super::TypeGroup::id).collect::<Vec<_>>(),
1414 vec!["code", "other"]
1415 );
1416 let family = registry.family("javascript").expect("display family retained");
1417 assert_eq!(family.label(), "JavaScript");
1418 assert_eq!(family.group_id(), "code");
1419 assert_eq!(family.extensions(), &[".js", ".js.map"]);
1420
1421 let source_map = registry.classify_name(OsStr::new("bundle.js.map"));
1422 assert_eq!(source_map.kind_id(), Some("javascript"));
1423 assert_eq!(source_map.family_id(), Some("javascript"));
1424 assert_eq!(source_map.group_id(), Some("code"));
1425 assert_eq!(source_map.content_family(), ContentFamily::Code);
1426 assert_eq!(source_map.logical_extension(), Some(".js.map"));
1427 assert_eq!(source_map.canonical_extension(), Some(".js.map"));
1428
1429 let makefile = registry.classify_name(OsStr::new("Makefile"));
1430 assert_eq!(makefile.kind_id(), Some("make"));
1431 assert_eq!(makefile.family_id(), None);
1432 assert_eq!(makefile.group_id(), Some("other"));
1433 assert_eq!(makefile.logical_extension(), None);
1434 assert_eq!(makefile.canonical_extension(), None);
1435
1436 let unknown = registry.classify_name(OsStr::new("release.v2.widget"));
1437 assert_eq!(unknown.logical_extension(), Some(".v2.widget"));
1438 assert_eq!(unknown.canonical_extension(), None);
1439 assert_eq!(registry.canonical_ext(OsStr::new("release.v2.widget")), None);
1442 assert_eq!(registry.canonical_ext(OsStr::new("bundle.js.map")).as_deref(), Some(".js.map"));
1443 assert_eq!(registry.canonical_ext(OsStr::new("Makefile")), None);
1444 assert_eq!(unknown.kind_id(), None);
1445 assert_eq!(unknown.family_id(), None);
1446 assert_eq!(unknown.group_id(), Some("other"));
1447 assert_eq!(unknown.content_family(), ContentFamily::Unknown);
1448 }
1449
1450 #[test]
1451 fn registry_identity_is_derived_from_semantics_not_formatting() {
1452 let compact = TypeRegistry::from_manifest(
1453 "[[kind]]\nid = \"notes\"\nfamily = \"prose\"\nextensions = [\"txt\"]\n",
1454 )
1455 .expect("compact manifest");
1456 let formatted = TypeRegistry::from_manifest(
1457 "# Equivalent rules with presentation-only differences.\r\n\r\n[[kind]]\r\n id = \"notes\"\r\n family = \"prose\"\r\n extensions = [ \"txt\" ]\r\n",
1458 )
1459 .expect("formatted manifest");
1460 let changed = TypeRegistry::from_manifest(
1461 "[[kind]]\nid = \"notes\"\nfamily = \"prose\"\nextensions = [\"text\"]\n",
1462 )
1463 .expect("different manifest");
1464
1465 assert_eq!(compact.fingerprint(), formatted.fingerprint());
1466 assert_ne!(compact.fingerprint(), changed.fingerprint());
1467 }
1468
1469 #[test]
1473 fn file_rollup_registry_identity_sees_a_key_move_between_tiers() {
1474 let registry = |extensions: &str, filenames: &str| {
1475 TypeRegistry::from_manifest(&format!(
1476 "schema_version = 3\nregistry_revision = 1\nmax_extension_components = 2\n\n\
1477 [[group]]\nid = \"other\"\nlabel = \"Other\"\norder = 10\n\n\
1478 [[kind]]\nid = \"notes\"\ngroup = \"other\"\ncontent_family = \"prose\"\n\
1479 extensions = [{extensions}]\nfilenames = [{filenames}]\nshebangs = []\n\
1480 priority = 100\n"
1481 ))
1482 .expect("File Rollup v3 registry parses")
1483 };
1484 let as_extension = registry("\"md\"", "");
1485 let as_filename = registry("", "\"md\"");
1486
1487 assert_eq!(as_extension.classify_name(OsStr::new("README.md")).kind_id(), Some("notes"));
1488 assert_eq!(as_filename.classify_name(OsStr::new("README.md")).kind_id(), None);
1489 assert_ne!(as_extension.fingerprint(), as_filename.fingerprint());
1490 }
1491
1492 #[test]
1496 fn a_schema_three_registry_and_its_schema_four_repaint_share_one_identity() {
1497 let registry = |schema: u32, group_icon: &str, hue: &str, family_icon: &str| {
1498 TypeRegistry::from_manifest(&format!(
1499 "schema_version = {schema}\nregistry_revision = 3\nmax_extension_components = 2\n\n\
1500 [[group]]\nid = \"code\"\nlabel = \"Code\"\norder = 10\n{group_icon}\n\
1501 [[group]]\nid = \"other\"\nlabel = \"Other\"\norder = 20\n{group_icon}\n\
1502 [[family]]\nid = \"swift\"\nlabel = \"Swift\"\ngroup = \"code\"\norder = 190\n\
1503 linguist = \"Swift\"\nlinguist_color = \"#f05138\"\nhue = {hue}\n{family_icon}\n\
1504 [[kind]]\nid = \"swift\"\nfamily = \"swift\"\ncontent_family = \"code\"\n\
1505 extensions = [\"swift\"]\nfilenames = []\nshebangs = []\npriority = 100\n"
1506 ))
1507 .unwrap_or_else(|error| panic!("schema {schema}: {error}"))
1508 };
1509 let three = registry(3, "", "31.62", "");
1510 let four = registry(
1511 4,
1512 "icon = \"alignLeft\"",
1513 "52.3",
1514 "deviation = \"\"\"Moved off svelte's hue.\"\"\"\nicon = \"fileText\"",
1515 );
1516
1517 assert_eq!(three.fingerprint(), four.fingerprint());
1518 assert_eq!(three.registry_revision(), four.registry_revision());
1519 let swift = OsStr::new("App.swift");
1520 assert_eq!(three.classify_name(swift).family_id(), Some("swift"));
1521 assert_eq!(four.classify_name(swift).family_id(), Some("swift"));
1522 }
1523
1524 #[test]
1527 fn non_utf8_names_still_reach_the_unknown_tier() {
1528 #[cfg(unix)]
1529 {
1530 use std::os::unix::ffi::OsStrExt;
1531 let name = OsStr::from_bytes(b"weird\xff\xfename");
1532 let classification = classify_path(Path::new(name));
1533 assert_eq!(classification.source, DetectionSource::Unknown);
1534 assert_eq!(classification.family, ContentFamily::Unknown);
1535 }
1536 }
1537
1538 #[test]
1539 fn plain_extensions_lowercase() {
1540 assert_eq!(derive_ext(OsStr::new("main.RS")).as_deref(), Some(".rs"));
1541 assert_eq!(derive_ext(OsStr::new("Photo.JPEG")).as_deref(), Some(".jpeg"));
1542 }
1543
1544 #[test]
1548 fn module_documentation_extension_table_matches_the_functions() {
1549 let rules = TypeRegistry::compiled();
1550 let mut rows = 0;
1551 for line in include_str!("classify.rs").lines().take_while(|line| line.starts_with("//!")) {
1552 let row = line.trim_start_matches("//!").trim();
1553 if !row.starts_with("| `") {
1554 continue;
1555 }
1556 let cells: Vec<Option<&str>> = row
1557 .trim_matches('|')
1558 .split('|')
1559 .map(|cell| match cell.trim() {
1560 "none" => None,
1561 cell => Some(cell.trim_matches('`')),
1562 })
1563 .collect();
1564 let [Some(name), raw, bucket, logical, canonical] = cells.as_slice() else {
1565 panic!("an example row has a name and four levels: {row}");
1566 };
1567 let name = OsStr::new(name);
1568 assert_eq!(derive_ext(name).as_deref(), *raw, "raw {name:?}");
1569 assert_eq!(Some(ext_bucket(name).as_str()), *bucket, "bucket {name:?}");
1570 assert_eq!(logical_ext(name).as_deref(), *logical, "logical {name:?}");
1571 assert_eq!(rules.canonical_ext(name).as_deref(), *canonical, "canonical {name:?}");
1572 rows += 1;
1573 }
1574 assert_eq!(rows, 5, "every example row in the module documentation was read");
1575 }
1576
1577 #[test]
1586 fn logical_canonical_and_raw_extensions_answer_different_questions() {
1587 let rules = TypeRegistry::compiled();
1588 for (name, logical, canonical, raw) in [
1589 ("archive.tar.gz", Some(".tar.gz"), Some(".tar.gz"), Some(".tar.gz")),
1590 ("release.v2.zip", Some(".v2.zip"), Some(".zip"), Some(".zip")),
1591 ("bundle.umd.min.js", Some(".min.js"), Some(".js"), Some(".js")),
1592 (".eslintrc.json", Some(".json"), Some(".json"), Some(".json")),
1593 (".gitignore", None, None, None),
1594 ("trailing.", None, None, None),
1595 ("notes.", None, None, None),
1596 ("release.v2.widget", Some(".v2.widget"), None, Some(".widget")),
1598 ("file.c++", None, None, Some(".c++")),
1600 ("notes.tar.gz~", None, None, Some(".tar.gz~")),
1601 ("a.b-c", None, None, Some(".b-c")),
1602 ("x.py_", None, None, Some(".py_")),
1603 ("x.abcdefghijklm", None, None, Some(".abcdefghijklm")),
1604 ("résumé.tëxt", None, None, Some(".tëxt")),
1605 ] {
1606 let name = OsStr::new(name);
1607 assert_eq!(logical_ext(name).as_deref(), logical, "logical {name:?}");
1608 assert_eq!(
1609 InlineExtension::logical(name).as_ref().map(InlineExtension::as_str),
1610 logical,
1611 "inline logical {name:?}"
1612 );
1613 assert_eq!(rules.canonical_ext(name).as_deref(), canonical, "canonical {name:?}");
1614 assert_eq!(
1615 rules.classify_name(name).canonical_extension(),
1616 canonical,
1617 "a row and a registry lookup agree about {name:?}"
1618 );
1619 assert_eq!(derive_ext(name).as_deref(), raw, "raw {name:?}");
1620 assert_eq!(ext_bucket(name), raw.unwrap_or(NO_EXTENSION), "bucket {name:?}");
1621 }
1622 }
1623
1624 #[test]
1626 fn inline_logical_extension_spells_what_logical_ext_does() {
1627 let twelve = "abcdefghijkl";
1628 let names = [
1629 "MAIN.RS".to_string(),
1630 "Archive.TAR.GZ".to_string(),
1631 format!("x.{twelve}.{}", twelve.to_uppercase()),
1633 format!("x.{twelve}m.{twelve}"),
1634 format!("x.{twelve}.{twelve}m"),
1635 ".hidden".to_string(),
1636 ".hidden.TOML".to_string(),
1637 "..".to_string(),
1638 ".".to_string(),
1639 String::new(),
1640 "a.".to_string(),
1641 "a..b".to_string(),
1642 "résumé.v2.tëxt".to_string(),
1643 "naïve.v2.Md".to_string(),
1644 ];
1645 let inline = |name: &OsStr| InlineExtension::logical(name).map(|e| e.as_str().to_owned());
1646 for name in &names {
1647 let name = OsStr::new(name);
1648 assert_eq!(inline(name), logical_ext(name), "{name:?}");
1649 }
1650
1651 #[cfg(unix)]
1652 {
1653 use std::os::unix::ffi::OsStrExt;
1654 let name = OsStr::from_bytes(b"caf\xe9.v2.TXT");
1655 assert_eq!(logical_ext(name).as_deref(), Some(".v2.txt"));
1656 assert_eq!(inline(name), logical_ext(name));
1657 }
1658 #[cfg(windows)]
1659 {
1660 use std::os::windows::ffi::OsStringExt;
1661 let units: Vec<u16> = [0xD800].into_iter().chain(".v2.TXT".encode_utf16()).collect();
1662 let name = std::ffi::OsString::from_wide(&units);
1663 assert_eq!(logical_ext(&name).as_deref(), Some(".v2.txt"));
1664 assert_eq!(inline(&name), logical_ext(&name));
1665 }
1666 }
1667
1668 #[test]
1669 fn tar_pairs_fold_into_one_extension() {
1670 assert_eq!(derive_ext(OsStr::new("archive.tar.gz")).as_deref(), Some(".tar.gz"));
1671 assert_eq!(derive_ext(OsStr::new("archive.tar.zst")).as_deref(), Some(".tar.zst"));
1672 assert_eq!(derive_ext(OsStr::new("archive.TAR.BZ2")).as_deref(), Some(".tar.bz2"));
1673 assert_eq!(derive_ext(OsStr::new("release.v2.zip")).as_deref(), Some(".zip"));
1675 }
1676
1677 #[test]
1678 fn names_without_a_usable_extension() {
1679 assert_eq!(derive_ext(OsStr::new("README")), None);
1680 assert_eq!(derive_ext(OsStr::new(".gitignore")), None);
1681 assert_eq!(derive_ext(OsStr::new(".bashrc")), None);
1682 assert_eq!(derive_ext(OsStr::new("trailing.")), None);
1683 assert_eq!(derive_ext(OsStr::new("")), None);
1684 }
1685
1686 #[test]
1687 fn dotfiles_with_a_real_extension_keep_it() {
1688 assert_eq!(derive_ext(OsStr::new(".eslintrc.json")).as_deref(), Some(".json"));
1689 }
1690
1691 #[test]
1692 fn compiled_rules_cover_exact_extension_compound_and_unknown_paths() {
1693 let makefile = classify_path(Path::new("Makefile"));
1694 assert_eq!(makefile.file_type.as_str(), "make");
1695 assert_eq!(makefile.source, DetectionSource::ExactFilename);
1696
1697 let rust = classify_path(Path::new("src/lib.RS"));
1698 assert_eq!(rust.file_type.as_str(), "rust");
1699 assert_eq!(rust.family, ContentFamily::Code);
1700 assert_eq!(rust.source, DetectionSource::Extension);
1701
1702 let archive = classify_path(Path::new("source.tar.ZST"));
1703 assert_eq!(archive.file_type.as_str(), "archive");
1704 assert_eq!(archive.source, DetectionSource::CompoundExtension);
1705
1706 let unknown = classify_path(Path::new("sample.widget"));
1707 assert_eq!(unknown.file_type.as_str(), "unknown:.widget");
1708 assert_eq!(unknown.family, ContentFamily::Unknown);
1709 assert_eq!(classify_path(Path::new("file.c++")).file_type.as_str(), "unknown:.c++");
1711 assert_ne!(type_rule_fingerprint(), 0);
1712 }
1713
1714 #[test]
1715 fn every_code_rule_has_a_canonical_human_language_name() {
1716 for rule in GENERATED_RULES.iter().filter(|rule| rule.family == ContentFamily::Code) {
1717 assert_ne!(
1718 human_language_name(rule.id),
1719 rule.id,
1720 "code rule {} needs a human-facing language name",
1721 rule.id
1722 );
1723 }
1724
1725 assert_eq!(human_language_name("css"), "CSS");
1726 assert_eq!(human_language_name("cpp"), "C++");
1727 assert_eq!(human_language_name("csharp"), "C#");
1728 assert_eq!(human_language_name("javascript"), "JavaScript");
1729 assert_eq!(human_language_name("powershell"), "PowerShell");
1730 assert_eq!(human_language_name("protobuf"), "Protocol Buffers");
1731 assert_eq!(human_language_name("unknown"), "unknown");
1732 }
1733
1734 #[test]
1735 fn bounded_prefix_resolves_shebangs_and_unknown_binary_files() {
1736 let python = classify_path_with_prefix(
1737 Path::new("script"),
1738 Some(b"#!/usr/bin/env -S python3 -I\nprint('ok')\n"),
1739 );
1740 assert_eq!(python.file_type.as_str(), "python");
1741 assert_eq!(python.source, DetectionSource::Shebang);
1742 assert_eq!(python.confidence, DetectionConfidence::High);
1743
1744 let binary = classify_path_with_prefix(Path::new("payload.unknown"), Some(b"abc\0def"));
1745 assert_eq!(binary.file_type.as_str(), "unknown:.unknown");
1746 assert_eq!(binary.family, ContentFamily::Binary);
1747 assert_eq!(binary.source, DetectionSource::ContentProbe);
1748
1749 let spoofed = classify_path_with_prefix(
1750 Path::new("payload"),
1751 Some(b"#!/usr/bin/env python3\ntext\0binary"),
1752 );
1753 assert_eq!(spoofed.family, ContentFamily::Binary);
1754 assert_eq!(spoofed.source, DetectionSource::ContentProbe);
1755 }
1756
1757 #[test]
1758 fn bounded_deep_detection_is_explainable() {
1759 let c = classify_path_with_prefix(Path::new("include/value.h"), Some(b"int value;\n"));
1760 assert_eq!(c.file_type.as_str(), "c");
1761 assert_eq!(c.source, DetectionSource::Extension);
1762
1763 let cpp = classify_path_with_prefix(
1764 Path::new("include/value.h"),
1765 Some(b"namespace demo { constexpr int value = 1; }\n"),
1766 );
1767 assert_eq!(cpp.file_type.as_str(), "cpp");
1768 assert_eq!(cpp.source, DetectionSource::AmbiguousContent);
1769 assert_eq!(cpp.confidence, DetectionConfidence::High);
1770
1771 let modeline = classify_path_with_prefix(
1772 Path::new("script.unknown"),
1773 Some(b"# vim: set filetype=rust:\nfn main() {}\n"),
1774 );
1775 assert_eq!(modeline.file_type.as_str(), "rust");
1776 assert_eq!(modeline.source, DetectionSource::Modeline);
1777
1778 let xml = classify_path_with_prefix(
1779 Path::new("document.unknown"),
1780 Some(b"<?xml version=\"1.0\"?><root/>"),
1781 );
1782 assert_eq!(xml.file_type.as_str(), "xml");
1783 assert_eq!(xml.source, DetectionSource::FormatSignature);
1784
1785 let manual = classify_path_with_prefix(
1786 Path::new("fdu.1"),
1787 Some(b".TH FDU 1\n.SH NAME\nfdu - disk usage\n"),
1788 );
1789 assert_eq!(manual.file_type.as_str(), "manpage");
1790
1791 let pdf = classify_path_with_prefix(Path::new("download"), Some(b"%PDF-1.7\n"));
1792 assert_eq!(pdf.file_type.as_str(), "pdf");
1793 assert_eq!(pdf.family, ContentFamily::Binary);
1794 }
1795
1796 #[cfg(unix)]
1797 #[test]
1798 fn ascii_extension_survives_non_unicode_stem() {
1799 use std::ffi::OsString;
1800 use std::os::unix::ffi::OsStringExt;
1801
1802 let name = OsString::from_vec(vec![b'n', 0xff, b'.', b'R', b'S']);
1803 assert_eq!(derive_ext(&name).as_deref(), Some(".rs"));
1804 }
1805
1806 #[cfg(windows)]
1807 #[test]
1808 fn ascii_extension_survives_unpaired_wide_stem() {
1809 use std::ffi::OsString;
1810 use std::os::windows::ffi::OsStringExt;
1811
1812 let name = OsString::from_wide(&[
1813 u16::from(b'n'),
1814 0xd800,
1815 u16::from(b'.'),
1816 u16::from(b'R'),
1817 u16::from(b'S'),
1818 ]);
1819 assert_eq!(derive_ext(&name).as_deref(), Some(".rs"));
1820 }
1821}