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