Skip to main content

use_python_module/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, str::FromStr};
5use std::error::Error;
6
7use use_python_identifier::{PythonIdentifier, PythonIdentifierError};
8
9macro_rules! dotted_name_newtype {
10    ($name:ident) => {
11        #[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
12        pub struct $name(String);
13
14        impl $name {
15            /// Creates lightly validated dotted Python name metadata.
16            ///
17            /// # Errors
18            ///
19            /// Returns [`PythonModuleNameError`] when `input` is empty, has empty segments, or contains invalid identifier segments.
20            pub fn new(input: &str) -> Result<Self, PythonModuleNameError> {
21                validate_dotted_name(input)?;
22                Ok(Self(input.to_string()))
23            }
24
25            /// Returns the dotted name.
26            #[must_use]
27            pub fn as_str(&self) -> &str {
28                &self.0
29            }
30
31            /// Returns the dotted name segments.
32            #[must_use]
33            pub fn segments(&self) -> Vec<&str> {
34                self.0.split('.').collect()
35            }
36        }
37
38        impl fmt::Display for $name {
39            fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
40                formatter.write_str(self.as_str())
41            }
42        }
43
44        impl FromStr for $name {
45            type Err = PythonModuleNameError;
46
47            fn from_str(input: &str) -> Result<Self, Self::Err> {
48                Self::new(input)
49            }
50        }
51
52        impl TryFrom<&str> for $name {
53            type Error = PythonModuleNameError;
54
55            fn try_from(value: &str) -> Result<Self, Self::Error> {
56                Self::new(value)
57            }
58        }
59    };
60}
61
62dotted_name_newtype!(PythonModuleName);
63dotted_name_newtype!(PythonPackageName);
64dotted_name_newtype!(PythonImportName);
65
66/// Python import statement kind metadata.
67#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
68pub enum PythonImportKind {
69    Absolute,
70    Relative,
71    FromImport,
72    StarImport,
73}
74
75impl PythonImportKind {
76    /// Returns the import kind label.
77    #[must_use]
78    pub const fn as_str(self) -> &'static str {
79        match self {
80            Self::Absolute => "absolute",
81            Self::Relative => "relative",
82            Self::FromImport => "from-import",
83            Self::StarImport => "star-import",
84        }
85    }
86}
87
88impl fmt::Display for PythonImportKind {
89    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
90        formatter.write_str(self.as_str())
91    }
92}
93
94impl FromStr for PythonImportKind {
95    type Err = PythonModuleNameError;
96
97    fn from_str(input: &str) -> Result<Self, Self::Err> {
98        match normalized_label(input)?.as_str() {
99            "absolute" => Ok(Self::Absolute),
100            "relative" => Ok(Self::Relative),
101            "fromimport" | "from" => Ok(Self::FromImport),
102            "starimport" | "star" => Ok(Self::StarImport),
103            _ => Err(PythonModuleNameError::UnknownLabel),
104        }
105    }
106}
107
108/// Python module path metadata.
109#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
110pub struct PythonModulePath(String);
111
112impl PythonModulePath {
113    /// Creates module path metadata.
114    ///
115    /// # Errors
116    ///
117    /// Returns [`PythonModuleNameError::Empty`] when `input` is empty after trimming.
118    pub fn new(input: &str) -> Result<Self, PythonModuleNameError> {
119        let trimmed = input.trim();
120        if trimmed.is_empty() {
121            Err(PythonModuleNameError::Empty)
122        } else {
123            Ok(Self(trimmed.to_string()))
124        }
125    }
126
127    /// Returns the path text.
128    #[must_use]
129    pub fn as_str(&self) -> &str {
130        &self.0
131    }
132}
133
134/// Python file-kind metadata.
135#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
136pub enum PythonFileKind {
137    Module,
138    PackageInit,
139    Script,
140    Test,
141    Stub,
142    Config,
143}
144
145impl PythonFileKind {
146    /// Returns the file kind label.
147    #[must_use]
148    pub const fn as_str(self) -> &'static str {
149        match self {
150            Self::Module => "module",
151            Self::PackageInit => "package-init",
152            Self::Script => "script",
153            Self::Test => "test",
154            Self::Stub => "stub",
155            Self::Config => "config",
156        }
157    }
158}
159
160impl fmt::Display for PythonFileKind {
161    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
162        formatter.write_str(self.as_str())
163    }
164}
165
166impl FromStr for PythonFileKind {
167    type Err = PythonModuleNameError;
168
169    fn from_str(input: &str) -> Result<Self, Self::Err> {
170        match normalized_label(input)?.as_str() {
171            "module" => Ok(Self::Module),
172            "packageinit" | "init" => Ok(Self::PackageInit),
173            "script" => Ok(Self::Script),
174            "test" => Ok(Self::Test),
175            "stub" | "pyi" => Ok(Self::Stub),
176            "config" => Ok(Self::Config),
177            _ => Err(PythonModuleNameError::UnknownLabel),
178        }
179    }
180}
181
182/// Python package layout metadata.
183#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
184pub enum PythonPackageLayout {
185    Flat,
186    Src,
187    NamespacePackage,
188}
189
190impl PythonPackageLayout {
191    /// Returns the package layout label.
192    #[must_use]
193    pub const fn as_str(self) -> &'static str {
194        match self {
195            Self::Flat => "flat",
196            Self::Src => "src",
197            Self::NamespacePackage => "namespace-package",
198        }
199    }
200}
201
202impl fmt::Display for PythonPackageLayout {
203    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
204        formatter.write_str(self.as_str())
205    }
206}
207
208impl FromStr for PythonPackageLayout {
209    type Err = PythonModuleNameError;
210
211    fn from_str(input: &str) -> Result<Self, Self::Err> {
212        match normalized_label(input)?.as_str() {
213            "flat" => Ok(Self::Flat),
214            "src" => Ok(Self::Src),
215            "namespacepackage" | "namespace" => Ok(Self::NamespacePackage),
216            _ => Err(PythonModuleNameError::UnknownLabel),
217        }
218    }
219}
220
221/// Error returned when Python module name metadata is invalid.
222#[derive(Clone, Copy, Debug, Eq, PartialEq)]
223pub enum PythonModuleNameError {
224    Empty,
225    EmptySegment,
226    Identifier(PythonIdentifierError),
227    UnknownLabel,
228}
229
230impl fmt::Display for PythonModuleNameError {
231    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
232        match self {
233            Self::Empty => formatter.write_str("Python module metadata cannot be empty"),
234            Self::EmptySegment => {
235                formatter.write_str("Python module name cannot contain empty segments")
236            }
237            Self::Identifier(error) => write!(formatter, "invalid Python module segment: {error}"),
238            Self::UnknownLabel => formatter.write_str("unknown Python module metadata label"),
239        }
240    }
241}
242
243impl Error for PythonModuleNameError {}
244
245fn validate_dotted_name(input: &str) -> Result<(), PythonModuleNameError> {
246    if input.trim().is_empty() {
247        return Err(PythonModuleNameError::Empty);
248    }
249
250    for segment in input.split('.') {
251        if segment.is_empty() {
252            return Err(PythonModuleNameError::EmptySegment);
253        }
254        PythonIdentifier::new(segment).map_err(PythonModuleNameError::Identifier)?;
255    }
256
257    Ok(())
258}
259
260fn normalized_label(input: &str) -> Result<String, PythonModuleNameError> {
261    let trimmed = input.trim();
262    if trimmed.is_empty() {
263        Err(PythonModuleNameError::Empty)
264    } else {
265        Ok(trimmed.to_ascii_lowercase().replace(['-', '_', ' '], ""))
266    }
267}
268
269#[cfg(test)]
270mod tests {
271    use super::{
272        PythonFileKind, PythonImportKind, PythonImportName, PythonModuleName,
273        PythonModuleNameError, PythonPackageLayout, PythonPackageName,
274    };
275
276    #[test]
277    fn validates_dotted_names() -> Result<(), PythonModuleNameError> {
278        let module = PythonModuleName::new("package.module")?;
279        let package = PythonPackageName::new("package")?;
280        let import_name = PythonImportName::new("package.submodule")?;
281
282        assert_eq!(module.segments(), vec!["package", "module"]);
283        assert_eq!(package.as_str(), "package");
284        assert_eq!(import_name.as_str(), "package.submodule");
285        Ok(())
286    }
287
288    #[test]
289    fn rejects_empty_or_invalid_segments() {
290        assert_eq!(PythonModuleName::new(""), Err(PythonModuleNameError::Empty));
291        assert_eq!(
292            PythonModuleName::new("package..module"),
293            Err(PythonModuleNameError::EmptySegment)
294        );
295        assert!(matches!(
296            PythonModuleName::new("package.class"),
297            Err(PythonModuleNameError::Identifier(_))
298        ));
299    }
300
301    #[test]
302    fn parses_and_displays_import_file_and_layout_labels() -> Result<(), PythonModuleNameError> {
303        assert_eq!(
304            "from-import".parse::<PythonImportKind>()?,
305            PythonImportKind::FromImport
306        );
307        assert_eq!(PythonImportKind::StarImport.to_string(), "star-import");
308        assert_eq!(
309            "package-init".parse::<PythonFileKind>()?,
310            PythonFileKind::PackageInit
311        );
312        assert_eq!(PythonFileKind::Stub.to_string(), "stub");
313        assert_eq!(
314            "namespace-package".parse::<PythonPackageLayout>()?,
315            PythonPackageLayout::NamespacePackage
316        );
317        assert_eq!(PythonPackageLayout::Src.to_string(), "src");
318        Ok(())
319    }
320}