1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4const COMPOUND_EXTENSIONS: [&str; 7] = [
5 "tar.gz",
6 "tar.bz2",
7 "tar.xz",
8 "d.ts",
9 "module.css",
10 "test.ts",
11 "spec.ts",
12];
13
14#[derive(Debug, Clone, PartialEq, Eq, Hash)]
16pub struct FileExtension {
17 pub value: String,
18}
19
20#[must_use]
22pub fn extension(input: &str) -> Option<String> {
23 let file_name = file_name_segment(input)?;
24 let (_, extension) = split_simple_extension(file_name)?;
25 Some(extension.to_string())
26}
27
28#[must_use]
30pub fn extension_lowercase(input: &str) -> Option<String> {
31 extension(input).map(|value| value.to_ascii_lowercase())
32}
33
34#[must_use]
36pub fn has_extension(input: &str) -> bool {
37 extension(input).is_some()
38}
39
40#[must_use]
42pub fn has_extension_eq(input: &str, extension: &str) -> bool {
43 let normalized = normalize_extension(extension);
44 !normalized.is_empty() && extension_lowercase(input).as_deref() == Some(normalized.as_str())
45}
46
47#[must_use]
49pub fn with_extension(input: &str, extension: &str) -> String {
50 let normalized_input = normalize_path_like(input);
51 let normalized_extension = normalize_extension(extension);
52
53 if normalized_input.is_empty() {
54 return String::new();
55 }
56
57 if normalized_extension.is_empty() {
58 return without_extension(&normalized_input);
59 }
60
61 let without = without_extension(&normalized_input);
62 if without.is_empty() {
63 String::new()
64 } else {
65 format!("{without}.{normalized_extension}")
66 }
67}
68
69#[must_use]
71pub fn without_extension(input: &str) -> String {
72 let normalized = normalize_path_like(input);
73 let (prefix, file_name) = split_directory_and_file_name(&normalized);
74 let Some(file_name) = file_name else {
75 return normalized;
76 };
77 let Some((stem, _)) = split_simple_extension(file_name) else {
78 return normalized;
79 };
80
81 format!("{prefix}{stem}")
82}
83
84#[must_use]
86pub fn normalize_extension(input: &str) -> String {
87 input.trim().trim_start_matches('.').to_ascii_lowercase()
88}
89
90#[must_use]
92pub fn is_compound_extension(input: &str) -> bool {
93 compound_extension(input).is_some()
94}
95
96#[must_use]
98pub fn compound_extension(input: &str) -> Option<String> {
99 let file_name = file_name_segment(input)?;
100 let normalized = file_name.to_ascii_lowercase();
101
102 for candidate in COMPOUND_EXTENSIONS {
103 let suffix = format!(".{candidate}");
104 if normalized.ends_with(&suffix) && normalized.len() > suffix.len() {
105 return Some(candidate.to_string());
106 }
107 }
108
109 None
110}
111
112fn normalize_path_like(input: &str) -> String {
113 input.replace('\\', "/")
114}
115
116fn file_name_segment(input: &str) -> Option<&str> {
117 let candidate = input.rsplit(['/', '\\']).next().unwrap_or(input);
118 (!candidate.is_empty()).then_some(candidate)
119}
120
121fn split_directory_and_file_name(input: &str) -> (&str, Option<&str>) {
122 match input.rfind('/') {
123 Some(index) => {
124 let file_name = (index + 1 < input.len()).then(|| &input[index + 1..]);
125 (&input[..=index], file_name)
126 }
127 None => ("", (!input.is_empty()).then_some(input)),
128 }
129}
130
131fn split_simple_extension(file_name: &str) -> Option<(&str, &str)> {
132 let dot_index = file_name.rfind('.')?;
133 if dot_index == file_name.len() - 1 {
134 return None;
135 }
136
137 if dot_index == 0 {
138 let nested_dot = file_name[1..].rfind('.')? + 1;
139 if nested_dot == file_name.len() - 1 {
140 return None;
141 }
142
143 return Some((&file_name[..nested_dot], &file_name[nested_dot + 1..]));
144 }
145
146 Some((&file_name[..dot_index], &file_name[dot_index + 1..]))
147}