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)]
14pub enum InvalidProjectMarker {
15    /// The marker was empty.
16    #[error("project marker cannot be empty")]
17    Empty,
18    /// The marker was `.` or `..`.
19    #[error("project marker cannot be '.' or '..'")]
20    RelativeComponent,
21    /// The marker contained a path separator or a drive designator, so it is
22    /// not one file name.
23    #[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/// The file or directory name whose presence marks a project root, such as
46/// `Cargo.toml`.
47///
48/// Exactly one file name: an absolute path would always match and a
49/// multi-component path would match at the workspace root but never deeper.
50/// Deserializes from a TOML string and rejects both at load time.
51///
52/// # Examples
53///
54/// ```
55/// use mcpls_core::config::ProjectMarker;
56///
57/// assert_eq!(ProjectMarker::new("Cargo.toml").unwrap(), "Cargo.toml");
58/// assert!(ProjectMarker::new("/etc/hosts").is_err());
59/// assert!(ProjectMarker::new("src/lib.rs").is_err());
60/// assert!(ProjectMarker::new("..").is_err());
61/// ```
62#[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/// Why a string is not a valid [`FileExtension`].
83#[derive(thiserror::Error, Debug, Clone, Copy, PartialEq, Eq)]
84pub enum InvalidFileExtension {
85    /// The extension was empty.
86    #[error("file extension cannot be empty")]
87    Empty,
88    /// The extension started with a dot, which `Path::extension` never reports.
89    #[error("file extension must be written without the leading dot")]
90    LeadingDot,
91    /// The extension contained a character other than an ASCII letter, an
92    /// ASCII digit, `_`, `-` or `+`.
93    #[error("file extension may contain only ASCII letters, digits, '_', '-' and '+', found {0:?}")]
94    InvalidChar(char),
95}
96
97/// Whether `byte` may appear in a file extension: an ASCII letter or digit,
98/// `_`, `-` or `+`.
99pub const fn is_pattern_name_byte(byte: u8) -> bool {
100    byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'+')
101}
102
103/// The first rule a name token breaks, without the offending character.
104#[derive(Clone, Copy)]
105enum TokenFault {
106    Empty,
107    LeadingDot,
108    InvalidByteAt(usize),
109}
110
111/// The one rule shared by [`FileExtension`] and [`FileName`]: non-empty and
112/// made only of [`is_pattern_name_byte`] bytes. A non-ASCII byte is always
113/// offending, so the index it reports is the start of a character.
114const 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
134/// The character of `token` that starts at byte `index`.
135fn 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/// A file extension as `Path::extension` reports it: no leading dot, and only
143/// ASCII letters, digits, `_`, `-` and `+`, so no glob or path characters
144/// that make a pattern ambiguous.
145///
146/// `extensions = [".rs"]` would load but never match, so a leading dot is
147/// rejected at load time. Case is significant, as in the extension map.
148///
149/// # Examples
150///
151/// ```
152/// use mcpls_core::config::FileExtension;
153///
154/// assert_eq!(FileExtension::new("rs").unwrap(), "rs");
155/// assert!(FileExtension::new(".rs").is_err());
156/// assert!(FileExtension::new("{cpp,h}").is_err());
157/// ```
158#[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/// Why a string is not a valid [`FileName`].
186#[derive(thiserror::Error, Debug, Clone, Copy, PartialEq, Eq)]
187pub enum InvalidFileName {
188    /// The name was empty.
189    #[error("file name cannot be empty")]
190    Empty,
191    /// The name contained a character other than an ASCII letter, an ASCII
192    /// digit, `_`, `-` or `+`; a dot is rejected, so a name never has an
193    /// extension.
194    #[error("file name may contain only ASCII letters, digits, '_', '-' and '+', found {0:?}")]
195    InvalidChar(char),
196}
197
198/// The name of an extensionless file such as `Makefile` or `Dockerfile`, as
199/// `Path::file_name` reports it.
200///
201/// A name has no dot, so `Path::extension` never reports one for it and the
202/// extension and name maps cannot both claim a file. Case is significant.
203///
204/// # Examples
205///
206/// ```
207/// use mcpls_core::config::FileName;
208///
209/// assert_eq!(FileName::new("Makefile").unwrap(), "Makefile");
210/// assert!(FileName::new("Cargo.toml").is_err());
211/// assert!(FileName::new(".eslintrc").is_err());
212/// ```
213#[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/// A `file_patterns` entry that cannot be mapped to a file extension or name.
241#[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    /// The rejected pattern text.
254    #[must_use]
255    pub fn pattern(&self) -> &str {
256        &self.pattern
257    }
258}
259
260/// What a [`FilePattern`] matches: every file with an extension, or the one
261/// extensionless file name.
262#[derive(Debug, Clone, PartialEq, Eq, Hash)]
263pub enum PatternTarget {
264    /// Files with this extension, from a `*.EXT` final segment.
265    Extension(FileExtension),
266    /// Files with exactly this name, from a bare `NAME` or `**/NAME` pattern.
267    Name(FileName),
268}
269
270/// A `file_patterns` entry: either an optional directory part followed by a
271/// final segment of the form `*.EXT`, or the bare name of an extensionless
272/// file written as `NAME` or `**/NAME`.
273///
274/// The extension or name is the only part mcpls reads, so the directory part
275/// of an extension pattern (such as `**/`) is kept for display and ignored for
276/// routing. A name pattern admits no directory part other than `**/`, so it
277/// never claims every file of that name below one directory. Every other form
278/// (brace expansion, character classes, `?`, dotted names, dotfiles, single
279/// files below a directory) has nothing to map and is rejected rather than
280/// dropped silently. [`Self::parse`] is the only constructor, so the config
281/// validator and the language-map builder cannot disagree on what is
282/// supported.
283///
284/// # Examples
285///
286/// ```
287/// use std::assert_matches;
288///
289/// use mcpls_core::config::{FilePattern, PatternTarget};
290///
291/// let pattern = FilePattern::parse("**/*.rs").unwrap();
292/// assert_matches!(pattern.target(), PatternTarget::Extension(e) if e == "rs");
293/// assert_eq!(pattern.as_str(), "**/*.rs");
294///
295/// let makefile = FilePattern::parse("**/Makefile").unwrap();
296/// assert_matches!(makefile.target(), PatternTarget::Name(n) if n == "Makefile");
297///
298/// assert!(FilePattern::parse("**/*.{cpp,h}").is_err());
299/// assert!(FilePattern::parse("src/main.rs").is_err());
300/// assert!(FilePattern::parse("docs/Makefile").is_err());
301/// assert!(FilePattern::parse("/Makefile").is_err());
302/// ```
303#[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    /// Parses `pattern`.
312    ///
313    /// # Errors
314    ///
315    /// Returns [`UnsupportedFilePattern`] if the final path segment (after the
316    /// last `/`) is neither `*.EXT` with a valid [`FileExtension`] nor a valid
317    /// [`FileName`] reached through no directory part but `**/`.
318    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    /// Parses a literal known to be supported, such as a built-in default.
340    ///
341    /// # Panics
342    ///
343    /// Panics if `pattern` is not a supported form.
344    #[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    /// The pattern text as configured.
353    #[must_use]
354    pub fn as_str(&self) -> &str {
355        &self.raw
356    }
357
358    /// The extension or file name this pattern maps.
359    #[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/// Every form the file pattern tests must see rejected, shared with the
390/// config loading tests.
391#[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}