Skip to main content

use_extension/
lib.rs

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/// A string-backed file extension.
15#[derive(Debug, Clone, PartialEq, Eq, Hash)]
16pub struct FileExtension {
17    pub value: String,
18}
19
20/// Extracts the last simple extension from a file name or path-like input.
21#[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/// Extracts the last simple extension and lowercases it.
29#[must_use]
30pub fn extension_lowercase(input: &str) -> Option<String> {
31    extension(input).map(|value| value.to_ascii_lowercase())
32}
33
34/// Returns `true` when a path-like input has a simple extension.
35#[must_use]
36pub fn has_extension(input: &str) -> bool {
37    extension(input).is_some()
38}
39
40/// Returns `true` when the last simple extension matches the given candidate.
41#[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/// Replaces the last simple extension or appends one when missing.
48#[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/// Removes the last simple extension while preserving directory segments.
70#[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/// Normalizes an extension by removing leading dots and lowercasing it.
85#[must_use]
86pub fn normalize_extension(input: &str) -> String {
87    input.trim().trim_start_matches('.').to_ascii_lowercase()
88}
89
90/// Returns `true` when the input ends with a supported compound extension.
91#[must_use]
92pub fn is_compound_extension(input: &str) -> bool {
93    compound_extension(input).is_some()
94}
95
96/// Extracts a supported compound extension from a file name or path-like input.
97#[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}