Skip to main content

mcpls_core/config/
patterns.rs

1//! Validated file-name tokens used to detect and route project files.
2//!
3//! Each type rejects at construction the inputs that would silently never
4//! match, or always match, at the use site.
5
6use std::borrow::Cow;
7
8use serde::{Deserialize, Serialize};
9
10use super::text_newtype::impl_text_newtype;
11
12/// Why a string is not a valid [`ProjectMarker`].
13#[derive(thiserror::Error, Debug, Clone, Copy, PartialEq, Eq)]
14#[non_exhaustive]
15pub enum InvalidProjectMarker {
16    /// The marker was empty.
17    #[error("project marker cannot be empty")]
18    Empty,
19    /// The marker was `.` or `..`.
20    #[error("project marker cannot be '.' or '..'")]
21    RelativeComponent,
22    /// The marker contained a path separator or a drive designator, so it is
23    /// not one file name.
24    #[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/// The file or directory name whose presence marks a project root, such as
47/// `Cargo.toml`.
48///
49/// Exactly one file name: an absolute path would always match and a
50/// multi-component path would match at the workspace root but never deeper.
51/// Deserializes from a TOML string and rejects both at load time.
52///
53/// # Examples
54///
55/// ```
56/// use mcpls_core::config::ProjectMarker;
57///
58/// assert_eq!(ProjectMarker::new("Cargo.toml").unwrap(), "Cargo.toml");
59/// assert!(ProjectMarker::new("/etc/hosts").is_err());
60/// assert!(ProjectMarker::new("src/lib.rs").is_err());
61/// assert!(ProjectMarker::new("..").is_err());
62/// ```
63#[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/// Why a string is not a valid [`FileExtension`].
75#[derive(thiserror::Error, Debug, Clone, Copy, PartialEq, Eq)]
76#[non_exhaustive]
77pub enum InvalidFileExtension {
78    /// The extension was empty.
79    #[error("file extension cannot be empty")]
80    Empty,
81    /// The extension started with a dot, which `Path::extension` never reports.
82    #[error("file extension must be written without the leading dot")]
83    LeadingDot,
84    /// The extension contained a character other than an ASCII letter, an
85    /// ASCII digit, `_`, `-` or `+`.
86    #[error("file extension may contain only ASCII letters, digits, '_', '-' and '+', found {0:?}")]
87    InvalidChar(char),
88}
89
90/// Whether `byte` may appear in a file extension: an ASCII letter or digit,
91/// `_`, `-` or `+`.
92pub const fn is_pattern_name_byte(byte: u8) -> bool {
93    byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'+')
94}
95
96/// The first rule a name token breaks, without the offending character.
97#[derive(Clone, Copy)]
98enum TokenFault {
99    Empty,
100    LeadingDot,
101    InvalidByteAt(usize),
102}
103
104/// The one rule shared by [`FileExtension`] and [`FileName`]: non-empty and
105/// made only of [`is_pattern_name_byte`] bytes. A non-ASCII byte is always
106/// offending, so the index it reports is the start of a character.
107const 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
127/// The character of `token` that starts at byte `index`.
128fn 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/// A file extension as `Path::extension` reports it: no leading dot, and only
136/// ASCII letters, digits, `_`, `-` and `+`, so no glob or path characters
137/// that make a pattern ambiguous.
138///
139/// `extensions = [".rs"]` would load but never match, so a leading dot is
140/// rejected at load time. Case is significant, as in the extension map.
141///
142/// # Examples
143///
144/// ```
145/// use mcpls_core::config::FileExtension;
146///
147/// assert_eq!(FileExtension::new("rs").unwrap(), "rs");
148/// assert!(FileExtension::new(".rs").is_err());
149/// assert!(FileExtension::new("{cpp,h}").is_err());
150/// ```
151#[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/// Why a string is not a valid [`FileName`].
174#[derive(thiserror::Error, Debug, Clone, Copy, PartialEq, Eq)]
175#[non_exhaustive]
176pub enum InvalidFileName {
177    /// The name was empty.
178    #[error("file name cannot be empty")]
179    Empty,
180    /// The name contained a character other than an ASCII letter, an ASCII
181    /// digit, `_`, `-` or `+`; a dot is rejected, so a name never has an
182    /// extension.
183    #[error("file name may contain only ASCII letters, digits, '_', '-' and '+', found {0:?}")]
184    InvalidChar(char),
185}
186
187/// The name of an extensionless file such as `Makefile` or `Dockerfile`, as
188/// `Path::file_name` reports it.
189///
190/// A name has no dot, so `Path::extension` never reports one for it and the
191/// extension and name maps cannot both claim a file. Case is significant.
192///
193/// # Examples
194///
195/// ```
196/// use mcpls_core::config::FileName;
197///
198/// assert_eq!(FileName::new("Makefile").unwrap(), "Makefile");
199/// assert!(FileName::new("Cargo.toml").is_err());
200/// assert!(FileName::new(".eslintrc").is_err());
201/// ```
202#[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/// A `file_patterns` entry that cannot be mapped to a file extension or name.
223#[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    /// The rejected pattern text.
236    #[must_use]
237    pub fn pattern(&self) -> &str {
238        &self.pattern
239    }
240}
241
242/// What a [`FilePattern`] matches: every file with an extension, or the one
243/// extensionless file name.
244#[derive(Debug, Clone, PartialEq, Eq, Hash)]
245pub enum PatternTarget {
246    /// Files with this extension, from a `*.EXT` final segment.
247    Extension(FileExtension),
248    /// Files with exactly this name, from a bare `NAME` or `**/NAME` pattern.
249    Name(FileName),
250}
251
252/// A `file_patterns` entry: either an optional directory part followed by a
253/// final segment of the form `*.EXT`, or the bare name of an extensionless
254/// file written as `NAME` or `**/NAME`.
255///
256/// The extension or name is the only part mcpls reads, so the directory part
257/// of an extension pattern (such as `**/`) is kept for display and ignored for
258/// routing. A name pattern admits no directory part other than `**/`, so it
259/// never claims every file of that name below one directory. Every other form
260/// (brace expansion, character classes, `?`, dotted names, dotfiles, single
261/// files below a directory) has nothing to map and is rejected rather than
262/// dropped silently. [`Self::parse`] is the only constructor, so the config
263/// validator and the language-map builder cannot disagree on what is
264/// supported.
265///
266/// # Examples
267///
268/// ```
269/// use std::assert_matches;
270///
271/// use mcpls_core::config::{FilePattern, PatternTarget};
272///
273/// let pattern = FilePattern::parse("**/*.rs").unwrap();
274/// assert_matches!(pattern.target(), PatternTarget::Extension(e) if e == "rs");
275/// assert_eq!(pattern.as_str(), "**/*.rs");
276///
277/// let makefile = FilePattern::parse("**/Makefile").unwrap();
278/// assert_matches!(makefile.target(), PatternTarget::Name(n) if n == "Makefile");
279///
280/// assert!(FilePattern::parse("**/*.{cpp,h}").is_err());
281/// assert!(FilePattern::parse("src/main.rs").is_err());
282/// assert!(FilePattern::parse("docs/Makefile").is_err());
283/// assert!(FilePattern::parse("/Makefile").is_err());
284/// ```
285#[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    /// Parses `pattern`.
294    ///
295    /// # Errors
296    ///
297    /// Returns [`UnsupportedFilePattern`] if the final path segment (after the
298    /// last `/`) is neither `*.EXT` with a valid [`FileExtension`] nor a valid
299    /// [`FileName`] reached through no directory part but `**/`.
300    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    /// Parses a literal known to be supported, such as a built-in default.
322    ///
323    /// # Panics
324    ///
325    /// Panics if `pattern` is not a supported form.
326    #[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    /// The pattern text as configured.
335    #[must_use]
336    pub fn as_str(&self) -> &str {
337        &self.raw
338    }
339
340    /// The extension or file name this pattern maps.
341    #[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/// Every form the file pattern tests must see rejected, shared with the
372/// config loading tests.
373#[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}