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 pub fn new(input: &str) -> Result<Self, PythonModuleNameError> {
21 validate_dotted_name(input)?;
22 Ok(Self(input.to_string()))
23 }
24
25 #[must_use]
27 pub fn as_str(&self) -> &str {
28 &self.0
29 }
30
31 #[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#[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 #[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#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
110pub struct PythonModulePath(String);
111
112impl PythonModulePath {
113 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 #[must_use]
129 pub fn as_str(&self) -> &str {
130 &self.0
131 }
132}
133
134#[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 #[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#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
184pub enum PythonPackageLayout {
185 Flat,
186 Src,
187 NamespacePackage,
188}
189
190impl PythonPackageLayout {
191 #[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#[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}