markdownlint-rs 0.1.0

A fast, flexible, configuration-based command-line interface for linting Markdown/CommonMark files
Documentation
use crate::error::{MarkdownlintError, Result};
use globset::{Glob, GlobSet, GlobSetBuilder};
use std::path::Path;

fn normalize_exclude_pattern(pattern: &str) -> String {
    if pattern.contains('*') || pattern.contains('?') || pattern.contains('[') {
        pattern.to_string()
    } else {
        format!("**/{pattern}/**")
    }
}

pub struct GlobMatcher {
    includes: GlobSet,
    excludes: GlobSet,
}

impl GlobMatcher {
    pub fn new(patterns: &[String]) -> Result<Self> {
        let mut include_builder = GlobSetBuilder::new();
        let mut exclude_builder = GlobSetBuilder::new();

        for pattern in patterns {
            if let Some(exclude_pattern) = pattern.strip_prefix('#') {
                let normalized = normalize_exclude_pattern(exclude_pattern);
                let glob = Glob::new(&normalized).map_err(|e| {
                    MarkdownlintError::InvalidGlob(format!(
                        "Invalid exclude pattern '{}': {}",
                        exclude_pattern, e
                    ))
                })?;
                exclude_builder.add(glob);
            } else {
                let glob = Glob::new(pattern).map_err(|e| {
                    MarkdownlintError::InvalidGlob(format!("Invalid pattern '{}': {}", pattern, e))
                })?;
                include_builder.add(glob);
            }
        }

        let includes = include_builder.build().map_err(|e| {
            MarkdownlintError::InvalidGlob(format!("Failed to build include glob set: {}", e))
        })?;

        let excludes = exclude_builder.build().map_err(|e| {
            MarkdownlintError::InvalidGlob(format!("Failed to build exclude glob set: {}", e))
        })?;

        Ok(Self { includes, excludes })
    }

    pub fn matches(&self, path: &Path) -> bool {
        if self.excludes.is_match(path) {
            return false;
        }

        if self.includes.is_empty() {
            return true;
        }

        self.includes.is_match(path)
    }

    pub fn has_patterns(&self) -> bool {
        !self.includes.is_empty() || !self.excludes.is_empty()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_include_pattern() {
        let matcher = GlobMatcher::new(&["*.md".to_string()]).unwrap();

        assert!(matcher.matches(Path::new("README.md")));
        assert!(matcher.matches(Path::new("docs/guide.md")));
        assert!(!matcher.matches(Path::new("README.txt")));
    }

    #[test]
    fn test_exclude_pattern() {
        let matcher = GlobMatcher::new(&["*.md".to_string(), "#node_modules".to_string()]).unwrap();

        assert!(matcher.matches(Path::new("README.md")));
        assert!(!matcher.matches(Path::new("node_modules/README.md")));
        assert!(!matcher.matches(Path::new("node_modules/package/file.md")));
    }

    #[test]
    fn test_recursive_pattern() {
        let matcher = GlobMatcher::new(&["**/*.md".to_string()]).unwrap();

        assert!(matcher.matches(Path::new("README.md")));
        assert!(matcher.matches(Path::new("docs/guide.md")));
        assert!(matcher.matches(Path::new("docs/api/reference.md")));
        assert!(!matcher.matches(Path::new("README.txt")));
    }

    #[test]
    fn test_multiple_excludes() {
        let matcher = GlobMatcher::new(&[
            "**/*.md".to_string(),
            "#node_modules".to_string(),
            "#target".to_string(),
        ])
        .unwrap();

        assert!(matcher.matches(Path::new("README.md")));
        assert!(matcher.matches(Path::new("docs/guide.md")));
        assert!(!matcher.matches(Path::new("node_modules/file.md")));
        assert!(!matcher.matches(Path::new("target/debug/file.md")));
    }

    #[test]
    fn test_empty_patterns() {
        let matcher = GlobMatcher::new(&[]).unwrap();

        assert!(matcher.matches(Path::new("README.md")));
        assert!(matcher.matches(Path::new("any/file.txt")));
    }

    #[test]
    fn test_has_patterns() {
        let empty_matcher = GlobMatcher::new(&[]).unwrap();
        assert!(!empty_matcher.has_patterns());

        let include_matcher = GlobMatcher::new(&["*.md".to_string()]).unwrap();
        assert!(include_matcher.has_patterns());

        let exclude_matcher = GlobMatcher::new(&["#node_modules".to_string()]).unwrap();
        assert!(exclude_matcher.has_patterns());
    }
}