use std::borrow::Cow;
use serde::{Deserialize, Serialize};
use super::text_newtype::impl_text_newtype;
#[derive(thiserror::Error, Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum InvalidProjectMarker {
#[error("project marker cannot be empty")]
Empty,
#[error("project marker cannot be '.' or '..'")]
RelativeComponent,
#[error("project marker must be a single file name, without '/', '\\' or ':'")]
NotAFileName,
}
impl InvalidProjectMarker {
const fn check(marker: &str) -> Option<Self> {
match marker.as_bytes() {
[] => return Some(Self::Empty),
[b'.'] | [b'.', b'.'] => return Some(Self::RelativeComponent),
_ => {}
}
let mut rest = marker.as_bytes();
while let [byte, tail @ ..] = rest {
if matches!(byte, b'/' | b'\\' | b':') {
return Some(Self::NotAFileName);
}
rest = tail;
}
None
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub struct ProjectMarker(Cow<'static, str>);
impl_text_newtype!(
ProjectMarker,
InvalidProjectMarker,
fault = InvalidProjectMarker::check,
"project marker"
);
#[derive(thiserror::Error, Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum InvalidFileExtension {
#[error("file extension cannot be empty")]
Empty,
#[error("file extension must be written without the leading dot")]
LeadingDot,
#[error("file extension may contain only ASCII letters, digits, '_', '-' and '+', found {0:?}")]
InvalidChar(char),
}
pub const fn is_pattern_name_byte(byte: u8) -> bool {
byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'+')
}
#[derive(Clone, Copy)]
enum TokenFault {
Empty,
LeadingDot,
InvalidByteAt(usize),
}
const fn first_token_fault(token: &str) -> Option<TokenFault> {
let bytes = token.as_bytes();
let mut rest = bytes;
if let [b'.', ..] = rest {
return Some(TokenFault::LeadingDot);
}
if rest.is_empty() {
return Some(TokenFault::Empty);
}
while let [byte, tail @ ..] = rest {
if !is_pattern_name_byte(*byte) {
return Some(TokenFault::InvalidByteAt(
bytes.len().saturating_sub(rest.len()),
));
}
rest = tail;
}
None
}
fn offender_at(token: &str, index: usize) -> char {
token
.get(index..)
.and_then(|rest| rest.chars().next())
.unwrap_or(char::REPLACEMENT_CHARACTER)
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub struct FileExtension(Cow<'static, str>);
fn explain_file_extension(extension: &str, fault: TokenFault) -> InvalidFileExtension {
match fault {
TokenFault::Empty => InvalidFileExtension::Empty,
TokenFault::LeadingDot => InvalidFileExtension::LeadingDot,
TokenFault::InvalidByteAt(index) => {
InvalidFileExtension::InvalidChar(offender_at(extension, index))
}
}
}
impl_text_newtype!(
FileExtension,
InvalidFileExtension,
fault = first_token_fault,
explain = explain_file_extension,
"file extension"
);
#[derive(thiserror::Error, Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum InvalidFileName {
#[error("file name cannot be empty")]
Empty,
#[error("file name may contain only ASCII letters, digits, '_', '-' and '+', found {0:?}")]
InvalidChar(char),
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub struct FileName(Cow<'static, str>);
fn explain_file_name(name: &str, fault: TokenFault) -> InvalidFileName {
match fault {
TokenFault::Empty => InvalidFileName::Empty,
TokenFault::LeadingDot => InvalidFileName::InvalidChar('.'),
TokenFault::InvalidByteAt(index) => InvalidFileName::InvalidChar(offender_at(name, index)),
}
}
impl_text_newtype!(
FileName,
InvalidFileName,
fault = first_token_fault,
explain = explain_file_name,
"file name"
);
#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
#[error(
"file pattern '{pattern}' is not supported: the final path segment must be `*.EXT`, where EXT \
is a run of letters, digits, '_', '-' or '+' (for example `**/*.rs`), or the bare name of an \
extensionless file written as `NAME` or `**/NAME` (for example `**/Makefile`); to cover \
several extensions list one pattern per extension, such as [\"**/*.cpp\", \"**/*.h\"]"
)]
pub struct UnsupportedFilePattern {
pattern: String,
}
impl UnsupportedFilePattern {
#[must_use]
pub fn pattern(&self) -> &str {
&self.pattern
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum PatternTarget {
Extension(FileExtension),
Name(FileName),
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
#[serde(into = "String")]
pub struct FilePattern {
raw: String,
target: PatternTarget,
}
impl FilePattern {
pub fn parse(pattern: &str) -> Result<Self, UnsupportedFilePattern> {
let (directory, basename) = pattern.rsplit_once('/').unwrap_or(("", pattern));
let target = match basename.strip_prefix("*.") {
Some(extension) => FileExtension::new(extension)
.ok()
.map(PatternTarget::Extension),
None if directory == "**" || !pattern.contains('/') => {
FileName::new(basename).ok().map(PatternTarget::Name)
}
None => None,
};
target
.map(|target| Self {
raw: pattern.to_owned(),
target,
})
.ok_or_else(|| UnsupportedFilePattern {
pattern: pattern.to_owned(),
})
}
#[must_use]
pub fn from_static(pattern: &'static str) -> Self {
match Self::parse(pattern) {
Ok(parsed) => parsed,
Err(e) => panic!("{e}"),
}
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.raw
}
#[must_use]
pub const fn target(&self) -> &PatternTarget {
&self.target
}
}
impl std::fmt::Display for FilePattern {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.raw)
}
}
impl From<FilePattern> for String {
fn from(pattern: FilePattern) -> Self {
pattern.raw
}
}
impl PartialEq<str> for FilePattern {
fn eq(&self, other: &str) -> bool {
self.raw == other
}
}
impl PartialEq<&str> for FilePattern {
fn eq(&self, other: &&str) -> bool {
self.raw == *other
}
}
#[cfg(test)]
pub(super) const UNSUPPORTED_FILE_PATTERNS: &[&str] = &[
"**/*.{cpp,h}",
"**/*.[ch]",
"**/*.ts?",
"**/*",
"src/**",
"docs/Makefile",
"/Makefile",
"src/**/Dockerfile",
"**/Makefile.am",
"**/.eslintrc",
".eslintrc",
"**/*.",
"**/*.tar.gz",
"src/main.rs",
"Cargo.toml",
"",
];
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_file_extension_rejects_dot_empty_and_glob_forms() {
assert_eq!(FileExtension::new(""), Err(InvalidFileExtension::Empty));
assert_eq!(
FileExtension::new(".rs"),
Err(InvalidFileExtension::LeadingDot)
);
for (bad, ch) in [
("{cpp,h}", '{'),
("[ch]", '['),
("ts?", '?'),
("*", '*'),
("tar.gz", '.'),
("a/b", '/'),
(" rs", ' '),
] {
assert_eq!(
FileExtension::new(bad),
Err(InvalidFileExtension::InvalidChar(ch)),
"{bad}"
);
}
}
#[test]
fn test_file_extension_rejects_non_ascii_with_the_offending_char() {
assert_eq!(
FileExtension::new("r\u{e9}s"),
Err(InvalidFileExtension::InvalidChar('\u{e9}'))
);
assert_eq!(
FileExtension::new("\u{4e2d}"),
Err(InvalidFileExtension::InvalidChar('\u{4e2d}'))
);
}
#[test]
#[should_panic(expected = "invalid file extension")]
fn test_file_extension_from_static_panics_on_non_ascii() {
let _ = FileExtension::from_static("r\u{e9}s");
}
#[test]
fn test_file_extension_accepts_case_and_symbols() {
for good in ["rs", "R", "c++", "d_ts", "tar-gz", "x1"] {
assert_eq!(FileExtension::new(good).unwrap(), good);
}
assert_ne!(
FileExtension::from_static("r"),
FileExtension::from_static("R")
);
}
#[test]
fn test_file_extension_serde_and_borrowed_lookup() {
let extension: FileExtension = serde_json::from_str("\"rs\"").unwrap();
assert_eq!(serde_json::to_string(&extension).unwrap(), "\"rs\"");
assert!(serde_json::from_str::<FileExtension>("\".rs\"").is_err());
let map = std::collections::HashMap::from([(extension, 1)]);
assert_eq!(map.get("rs"), Some(&1));
}
#[test]
#[should_panic(expected = "invalid file extension")]
fn test_file_extension_from_static_panics_on_invalid() {
let _ = FileExtension::from_static(".rs");
}
#[test]
fn test_file_pattern_accepts_trailing_extension_forms() {
for (raw, ext) in [
("**/*.rs", "rs"),
("*.h", "h"),
("src/**/*.tsx", "tsx"),
("**/*.c++", "c++"),
("**/*.R", "R"),
] {
let pattern = FilePattern::parse(raw).unwrap();
assert_eq!(
pattern.target(),
&PatternTarget::Extension(FileExtension::from_static(ext)),
"{raw}"
);
assert_eq!(pattern.as_str(), raw);
}
}
#[test]
fn test_file_pattern_accepts_bare_and_globstar_names() {
for (raw, name) in [
("Makefile", "Makefile"),
("**/Dockerfile", "Dockerfile"),
("**/Justfile", "Justfile"),
] {
let pattern = FilePattern::parse(raw).unwrap();
assert_eq!(
pattern.target(),
&PatternTarget::Name(FileName::from_static(name)),
"{raw}"
);
assert_eq!(pattern.as_str(), raw);
}
}
#[test]
fn test_file_name_rejects_dots_empty_and_glob_forms() {
assert_eq!(FileName::new(""), Err(InvalidFileName::Empty));
for (bad, ch) in [
(".eslintrc", '.'),
("Cargo.toml", '.'),
("Make*", '*'),
("a/b", '/'),
("m\u{e9}", '\u{e9}'),
] {
assert_eq!(
FileName::new(bad),
Err(InvalidFileName::InvalidChar(ch)),
"{bad}"
);
}
}
#[test]
fn test_file_name_serde_and_borrowed_lookup() {
let name: FileName = serde_json::from_str("\"Makefile\"").unwrap();
assert_eq!(serde_json::to_string(&name).unwrap(), "\"Makefile\"");
assert!(serde_json::from_str::<FileName>("\"a.b\"").is_err());
let map = std::collections::HashMap::from([(name, 1)]);
assert_eq!(map.get("Makefile"), Some(&1));
}
#[test]
#[should_panic(expected = "invalid file name")]
fn test_file_name_from_static_panics_on_invalid() {
let _ = FileName::from_static("a.b");
}
#[test]
fn test_file_pattern_rejects_every_unsupported_form() {
for &raw in UNSUPPORTED_FILE_PATTERNS {
let err = FilePattern::parse(raw).unwrap_err();
assert_eq!(err.pattern(), raw);
assert!(err.to_string().contains(&format!("'{raw}'")), "{raw}");
assert!(err.to_string().contains("*.EXT"), "{raw}");
}
}
#[test]
fn test_file_pattern_serializes_as_configured_text() {
let pattern = FilePattern::from_static("**/*.rs");
assert_eq!(serde_json::to_string(&pattern).unwrap(), "\"**/*.rs\"");
assert_eq!(pattern.to_string(), "**/*.rs");
}
#[test]
#[should_panic(expected = "is not supported")]
fn test_file_pattern_from_static_panics_on_unsupported() {
let _ = FilePattern::from_static("**/*.{a,b}");
}
#[test]
fn test_project_marker_rejects_non_file_names() {
assert_eq!(ProjectMarker::new(""), Err(InvalidProjectMarker::Empty));
assert_eq!(
ProjectMarker::new("."),
Err(InvalidProjectMarker::RelativeComponent)
);
assert_eq!(
ProjectMarker::new(".."),
Err(InvalidProjectMarker::RelativeComponent)
);
for bad in ["/etc/hosts", "../x", "src/x", r"a\b", "C:", "C:\\x"] {
assert_eq!(
ProjectMarker::new(bad),
Err(InvalidProjectMarker::NotAFileName),
"{bad}"
);
}
}
#[test]
fn test_project_marker_accepts_dotfiles_and_dotted_names() {
for good in ["Cargo.toml", ".clangd", "..hidden", "build.zig.zon"] {
assert_eq!(ProjectMarker::new(good).unwrap(), good);
}
}
#[test]
fn test_project_marker_serde_round_trip_and_rejection() {
#[derive(Debug, Serialize, Deserialize, PartialEq)]
struct Holder {
marker: ProjectMarker,
}
let holder: Holder = toml::from_str(r#"marker = "go.mod""#).unwrap();
assert_eq!(holder.marker, ProjectMarker::from_static("go.mod"));
assert_eq!(
toml::to_string(&holder).unwrap().trim(),
r#"marker = "go.mod""#
);
assert!(toml::from_str::<Holder>(r#"marker = "a/b""#).is_err());
}
#[test]
#[should_panic(expected = "invalid project marker")]
fn test_project_marker_from_static_panics_on_invalid() {
let _ = ProjectMarker::from_static("a/b");
}
}