1use std::borrow::Cow;
7
8use serde::{Deserialize, Serialize};
9
10use super::text_newtype::impl_text_newtype;
11
12#[derive(thiserror::Error, Debug, Clone, Copy, PartialEq, Eq)]
14#[non_exhaustive]
15pub enum InvalidProjectMarker {
16 #[error("project marker cannot be empty")]
18 Empty,
19 #[error("project marker cannot be '.' or '..'")]
21 RelativeComponent,
22 #[error("project marker must be a single file name, without '/', '\\' or ':'")]
25 NotAFileName,
26}
27
28impl InvalidProjectMarker {
29 const fn check(marker: &str) -> Option<Self> {
30 match marker.as_bytes() {
31 [] => return Some(Self::Empty),
32 [b'.'] | [b'.', b'.'] => return Some(Self::RelativeComponent),
33 _ => {}
34 }
35 let mut rest = marker.as_bytes();
36 while let [byte, tail @ ..] = rest {
37 if matches!(byte, b'/' | b'\\' | b':') {
38 return Some(Self::NotAFileName);
39 }
40 rest = tail;
41 }
42 None
43 }
44}
45
46#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
64#[serde(try_from = "String", into = "String")]
65pub struct ProjectMarker(Cow<'static, str>);
66
67impl_text_newtype!(
68 ProjectMarker,
69 InvalidProjectMarker,
70 fault = InvalidProjectMarker::check,
71 "project marker"
72);
73
74#[derive(thiserror::Error, Debug, Clone, Copy, PartialEq, Eq)]
76#[non_exhaustive]
77pub enum InvalidFileExtension {
78 #[error("file extension cannot be empty")]
80 Empty,
81 #[error("file extension must be written without the leading dot")]
83 LeadingDot,
84 #[error("file extension may contain only ASCII letters, digits, '_', '-' and '+', found {0:?}")]
87 InvalidChar(char),
88}
89
90pub const fn is_pattern_name_byte(byte: u8) -> bool {
93 byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'+')
94}
95
96#[derive(Clone, Copy)]
98enum TokenFault {
99 Empty,
100 LeadingDot,
101 InvalidByteAt(usize),
102}
103
104const fn first_token_fault(token: &str) -> Option<TokenFault> {
108 let bytes = token.as_bytes();
109 let mut rest = bytes;
110 if let [b'.', ..] = rest {
111 return Some(TokenFault::LeadingDot);
112 }
113 if rest.is_empty() {
114 return Some(TokenFault::Empty);
115 }
116 while let [byte, tail @ ..] = rest {
117 if !is_pattern_name_byte(*byte) {
118 return Some(TokenFault::InvalidByteAt(
119 bytes.len().saturating_sub(rest.len()),
120 ));
121 }
122 rest = tail;
123 }
124 None
125}
126
127fn offender_at(token: &str, index: usize) -> char {
129 token
130 .get(index..)
131 .and_then(|rest| rest.chars().next())
132 .unwrap_or(char::REPLACEMENT_CHARACTER)
133}
134
135#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
152#[serde(try_from = "String", into = "String")]
153pub struct FileExtension(Cow<'static, str>);
154
155fn explain_file_extension(extension: &str, fault: TokenFault) -> InvalidFileExtension {
156 match fault {
157 TokenFault::Empty => InvalidFileExtension::Empty,
158 TokenFault::LeadingDot => InvalidFileExtension::LeadingDot,
159 TokenFault::InvalidByteAt(index) => {
160 InvalidFileExtension::InvalidChar(offender_at(extension, index))
161 }
162 }
163}
164
165impl_text_newtype!(
166 FileExtension,
167 InvalidFileExtension,
168 fault = first_token_fault,
169 explain = explain_file_extension,
170 "file extension"
171);
172
173#[derive(thiserror::Error, Debug, Clone, Copy, PartialEq, Eq)]
175#[non_exhaustive]
176pub enum InvalidFileName {
177 #[error("file name cannot be empty")]
179 Empty,
180 #[error("file name may contain only ASCII letters, digits, '_', '-' and '+', found {0:?}")]
184 InvalidChar(char),
185}
186
187#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
203#[serde(try_from = "String", into = "String")]
204pub struct FileName(Cow<'static, str>);
205
206fn explain_file_name(name: &str, fault: TokenFault) -> InvalidFileName {
207 match fault {
208 TokenFault::Empty => InvalidFileName::Empty,
209 TokenFault::LeadingDot => InvalidFileName::InvalidChar('.'),
210 TokenFault::InvalidByteAt(index) => InvalidFileName::InvalidChar(offender_at(name, index)),
211 }
212}
213
214impl_text_newtype!(
215 FileName,
216 InvalidFileName,
217 fault = first_token_fault,
218 explain = explain_file_name,
219 "file name"
220);
221
222#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
224#[error(
225 "file pattern '{pattern}' is not supported: the final path segment must be `*.EXT`, where EXT \
226 is a run of letters, digits, '_', '-' or '+' (for example `**/*.rs`), or the bare name of an \
227 extensionless file written as `NAME` or `**/NAME` (for example `**/Makefile`); to cover \
228 several extensions list one pattern per extension, such as [\"**/*.cpp\", \"**/*.h\"]"
229)]
230pub struct UnsupportedFilePattern {
231 pattern: String,
232}
233
234impl UnsupportedFilePattern {
235 #[must_use]
237 pub fn pattern(&self) -> &str {
238 &self.pattern
239 }
240}
241
242#[derive(Debug, Clone, PartialEq, Eq, Hash)]
245pub enum PatternTarget {
246 Extension(FileExtension),
248 Name(FileName),
250}
251
252#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
286#[serde(into = "String")]
287pub struct FilePattern {
288 raw: String,
289 target: PatternTarget,
290}
291
292impl FilePattern {
293 pub fn parse(pattern: &str) -> Result<Self, UnsupportedFilePattern> {
301 let (directory, basename) = pattern.rsplit_once('/').unwrap_or(("", pattern));
302 let target = match basename.strip_prefix("*.") {
303 Some(extension) => FileExtension::new(extension)
304 .ok()
305 .map(PatternTarget::Extension),
306 None if directory == "**" || !pattern.contains('/') => {
307 FileName::new(basename).ok().map(PatternTarget::Name)
308 }
309 None => None,
310 };
311 target
312 .map(|target| Self {
313 raw: pattern.to_owned(),
314 target,
315 })
316 .ok_or_else(|| UnsupportedFilePattern {
317 pattern: pattern.to_owned(),
318 })
319 }
320
321 #[must_use]
327 pub fn from_static(pattern: &'static str) -> Self {
328 match Self::parse(pattern) {
329 Ok(parsed) => parsed,
330 Err(e) => panic!("{e}"),
331 }
332 }
333
334 #[must_use]
336 pub fn as_str(&self) -> &str {
337 &self.raw
338 }
339
340 #[must_use]
342 pub const fn target(&self) -> &PatternTarget {
343 &self.target
344 }
345}
346
347impl std::fmt::Display for FilePattern {
348 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
349 f.write_str(&self.raw)
350 }
351}
352
353impl From<FilePattern> for String {
354 fn from(pattern: FilePattern) -> Self {
355 pattern.raw
356 }
357}
358
359impl PartialEq<str> for FilePattern {
360 fn eq(&self, other: &str) -> bool {
361 self.raw == other
362 }
363}
364
365impl PartialEq<&str> for FilePattern {
366 fn eq(&self, other: &&str) -> bool {
367 self.raw == *other
368 }
369}
370
371#[cfg(test)]
374pub(super) const UNSUPPORTED_FILE_PATTERNS: &[&str] = &[
375 "**/*.{cpp,h}",
376 "**/*.[ch]",
377 "**/*.ts?",
378 "**/*",
379 "src/**",
380 "docs/Makefile",
381 "/Makefile",
382 "src/**/Dockerfile",
383 "**/Makefile.am",
384 "**/.eslintrc",
385 ".eslintrc",
386 "**/*.",
387 "**/*.tar.gz",
388 "src/main.rs",
389 "Cargo.toml",
390 "",
391];
392
393#[cfg(test)]
394mod tests {
395 use super::*;
396
397 #[test]
398 fn test_file_extension_rejects_dot_empty_and_glob_forms() {
399 assert_eq!(FileExtension::new(""), Err(InvalidFileExtension::Empty));
400 assert_eq!(
401 FileExtension::new(".rs"),
402 Err(InvalidFileExtension::LeadingDot)
403 );
404 for (bad, ch) in [
405 ("{cpp,h}", '{'),
406 ("[ch]", '['),
407 ("ts?", '?'),
408 ("*", '*'),
409 ("tar.gz", '.'),
410 ("a/b", '/'),
411 (" rs", ' '),
412 ] {
413 assert_eq!(
414 FileExtension::new(bad),
415 Err(InvalidFileExtension::InvalidChar(ch)),
416 "{bad}"
417 );
418 }
419 }
420
421 #[test]
422 fn test_file_extension_rejects_non_ascii_with_the_offending_char() {
423 assert_eq!(
424 FileExtension::new("r\u{e9}s"),
425 Err(InvalidFileExtension::InvalidChar('\u{e9}'))
426 );
427 assert_eq!(
428 FileExtension::new("\u{4e2d}"),
429 Err(InvalidFileExtension::InvalidChar('\u{4e2d}'))
430 );
431 }
432
433 #[test]
434 #[should_panic(expected = "invalid file extension")]
435 fn test_file_extension_from_static_panics_on_non_ascii() {
436 let _ = FileExtension::from_static("r\u{e9}s");
437 }
438
439 #[test]
440 fn test_file_extension_accepts_case_and_symbols() {
441 for good in ["rs", "R", "c++", "d_ts", "tar-gz", "x1"] {
442 assert_eq!(FileExtension::new(good).unwrap(), good);
443 }
444 assert_ne!(
445 FileExtension::from_static("r"),
446 FileExtension::from_static("R")
447 );
448 }
449
450 #[test]
451 fn test_file_extension_serde_and_borrowed_lookup() {
452 let extension: FileExtension = serde_json::from_str("\"rs\"").unwrap();
453 assert_eq!(serde_json::to_string(&extension).unwrap(), "\"rs\"");
454 assert!(serde_json::from_str::<FileExtension>("\".rs\"").is_err());
455 let map = std::collections::HashMap::from([(extension, 1)]);
456 assert_eq!(map.get("rs"), Some(&1));
457 }
458
459 #[test]
460 #[should_panic(expected = "invalid file extension")]
461 fn test_file_extension_from_static_panics_on_invalid() {
462 let _ = FileExtension::from_static(".rs");
463 }
464
465 #[test]
466 fn test_file_pattern_accepts_trailing_extension_forms() {
467 for (raw, ext) in [
468 ("**/*.rs", "rs"),
469 ("*.h", "h"),
470 ("src/**/*.tsx", "tsx"),
471 ("**/*.c++", "c++"),
472 ("**/*.R", "R"),
473 ] {
474 let pattern = FilePattern::parse(raw).unwrap();
475 assert_eq!(
476 pattern.target(),
477 &PatternTarget::Extension(FileExtension::from_static(ext)),
478 "{raw}"
479 );
480 assert_eq!(pattern.as_str(), raw);
481 }
482 }
483
484 #[test]
485 fn test_file_pattern_accepts_bare_and_globstar_names() {
486 for (raw, name) in [
487 ("Makefile", "Makefile"),
488 ("**/Dockerfile", "Dockerfile"),
489 ("**/Justfile", "Justfile"),
490 ] {
491 let pattern = FilePattern::parse(raw).unwrap();
492 assert_eq!(
493 pattern.target(),
494 &PatternTarget::Name(FileName::from_static(name)),
495 "{raw}"
496 );
497 assert_eq!(pattern.as_str(), raw);
498 }
499 }
500
501 #[test]
502 fn test_file_name_rejects_dots_empty_and_glob_forms() {
503 assert_eq!(FileName::new(""), Err(InvalidFileName::Empty));
504 for (bad, ch) in [
505 (".eslintrc", '.'),
506 ("Cargo.toml", '.'),
507 ("Make*", '*'),
508 ("a/b", '/'),
509 ("m\u{e9}", '\u{e9}'),
510 ] {
511 assert_eq!(
512 FileName::new(bad),
513 Err(InvalidFileName::InvalidChar(ch)),
514 "{bad}"
515 );
516 }
517 }
518
519 #[test]
520 fn test_file_name_serde_and_borrowed_lookup() {
521 let name: FileName = serde_json::from_str("\"Makefile\"").unwrap();
522 assert_eq!(serde_json::to_string(&name).unwrap(), "\"Makefile\"");
523 assert!(serde_json::from_str::<FileName>("\"a.b\"").is_err());
524 let map = std::collections::HashMap::from([(name, 1)]);
525 assert_eq!(map.get("Makefile"), Some(&1));
526 }
527
528 #[test]
529 #[should_panic(expected = "invalid file name")]
530 fn test_file_name_from_static_panics_on_invalid() {
531 let _ = FileName::from_static("a.b");
532 }
533
534 #[test]
535 fn test_file_pattern_rejects_every_unsupported_form() {
536 for &raw in UNSUPPORTED_FILE_PATTERNS {
537 let err = FilePattern::parse(raw).unwrap_err();
538 assert_eq!(err.pattern(), raw);
539 assert!(err.to_string().contains(&format!("'{raw}'")), "{raw}");
540 assert!(err.to_string().contains("*.EXT"), "{raw}");
541 }
542 }
543
544 #[test]
545 fn test_file_pattern_serializes_as_configured_text() {
546 let pattern = FilePattern::from_static("**/*.rs");
547 assert_eq!(serde_json::to_string(&pattern).unwrap(), "\"**/*.rs\"");
548 assert_eq!(pattern.to_string(), "**/*.rs");
549 }
550
551 #[test]
552 #[should_panic(expected = "is not supported")]
553 fn test_file_pattern_from_static_panics_on_unsupported() {
554 let _ = FilePattern::from_static("**/*.{a,b}");
555 }
556
557 #[test]
558 fn test_project_marker_rejects_non_file_names() {
559 assert_eq!(ProjectMarker::new(""), Err(InvalidProjectMarker::Empty));
560 assert_eq!(
561 ProjectMarker::new("."),
562 Err(InvalidProjectMarker::RelativeComponent)
563 );
564 assert_eq!(
565 ProjectMarker::new(".."),
566 Err(InvalidProjectMarker::RelativeComponent)
567 );
568 for bad in ["/etc/hosts", "../x", "src/x", r"a\b", "C:", "C:\\x"] {
569 assert_eq!(
570 ProjectMarker::new(bad),
571 Err(InvalidProjectMarker::NotAFileName),
572 "{bad}"
573 );
574 }
575 }
576
577 #[test]
578 fn test_project_marker_accepts_dotfiles_and_dotted_names() {
579 for good in ["Cargo.toml", ".clangd", "..hidden", "build.zig.zon"] {
580 assert_eq!(ProjectMarker::new(good).unwrap(), good);
581 }
582 }
583
584 #[test]
585 fn test_project_marker_serde_round_trip_and_rejection() {
586 #[derive(Debug, Serialize, Deserialize, PartialEq)]
587 struct Holder {
588 marker: ProjectMarker,
589 }
590
591 let holder: Holder = toml::from_str(r#"marker = "go.mod""#).unwrap();
592 assert_eq!(holder.marker, ProjectMarker::from_static("go.mod"));
593 assert_eq!(
594 toml::to_string(&holder).unwrap().trim(),
595 r#"marker = "go.mod""#
596 );
597 assert!(toml::from_str::<Holder>(r#"marker = "a/b""#).is_err());
598 }
599
600 #[test]
601 #[should_panic(expected = "invalid project marker")]
602 fn test_project_marker_from_static_panics_on_invalid() {
603 let _ = ProjectMarker::from_static("a/b");
604 }
605}