Skip to main content

mdlint/glob/
walker.rs

1use crate::error::{MarkdownlintError, Result};
2use crate::glob::GlobMatcher;
3use ignore::WalkBuilder;
4use std::path::{Path, PathBuf};
5
6const MARKDOWN_EXTENSIONS: &[&str] = &[
7    "md", "markdown", "mdown", "mkdn", "mkd", "mdwn", "mdtxt", "mdtext",
8];
9
10pub struct FileWalker {
11    respect_gitignore: bool,
12}
13
14impl FileWalker {
15    pub fn new(respect_gitignore: bool) -> Self {
16        Self { respect_gitignore }
17    }
18
19    pub fn find_markdown_files(&self, root: &Path) -> Result<Vec<PathBuf>> {
20        self.walk_files(root, None)
21    }
22
23    pub fn find_files_with_matcher(
24        &self,
25        root: &Path,
26        matcher: &GlobMatcher,
27    ) -> Result<Vec<PathBuf>> {
28        if !matcher.has_patterns() {
29            return self.find_markdown_files(root);
30        }
31
32        self.walk_files(root, Some(matcher))
33    }
34
35    fn walk_files(&self, root: &Path, matcher: Option<&GlobMatcher>) -> Result<Vec<PathBuf>> {
36        let root = root.canonicalize().map_err(MarkdownlintError::Io)?;
37        let mut builder = WalkBuilder::new(&root);
38        builder.git_ignore(self.respect_gitignore);
39        builder.git_global(self.respect_gitignore);
40        builder.git_exclude(self.respect_gitignore);
41        builder.hidden(false);
42
43        let mut files = Vec::new();
44        for entry in builder.build() {
45            let entry = entry.map_err(|e| {
46                MarkdownlintError::Io(std::io::Error::other(format!("Walk error: {}", e)))
47            })?;
48            if !(entry.file_type().is_some_and(|ft| ft.is_file())) {
49                continue;
50            }
51
52            let path = entry.path();
53            if !is_markdown_file(path) {
54                continue;
55            }
56
57            if let Some(m) = matcher {
58                let relative_path = path.strip_prefix(&root).unwrap_or(path);
59                if m.matches(relative_path) {
60                    files.push(path.to_path_buf());
61                }
62            } else {
63                files.push(path.to_path_buf());
64            }
65        }
66        Ok(files)
67    }
68}
69
70fn is_markdown_file(path: &Path) -> bool {
71    path.extension()
72        .and_then(|ext| ext.to_str())
73        .map(|ext| MARKDOWN_EXTENSIONS.contains(&ext))
74        .unwrap_or(false)
75}
76
77#[cfg(test)]
78mod tests {
79    use super::*;
80    use std::fs;
81    use std::io::Write;
82    use tempfile::TempDir;
83
84    #[test]
85    fn test_find_markdown_files() {
86        let temp_dir = TempDir::new().unwrap();
87
88        fs::File::create(temp_dir.path().join("README.md")).unwrap();
89        fs::File::create(temp_dir.path().join("test.txt")).unwrap();
90        fs::File::create(temp_dir.path().join("guide.markdown")).unwrap();
91
92        let walker = FileWalker::new(false);
93        let files = walker.find_markdown_files(temp_dir.path()).unwrap();
94
95        assert_eq!(files.len(), 2);
96        assert!(files.iter().any(|p| p.ends_with("README.md")));
97        assert!(files.iter().any(|p| p.ends_with("guide.markdown")));
98    }
99
100    #[test]
101    fn test_find_nested_markdown_files() {
102        let temp_dir = TempDir::new().unwrap();
103        let docs_dir = temp_dir.path().join("docs");
104        fs::create_dir(&docs_dir).unwrap();
105
106        fs::File::create(temp_dir.path().join("README.md")).unwrap();
107        fs::File::create(docs_dir.join("guide.md")).unwrap();
108
109        let walker = FileWalker::new(false);
110        let files = walker.find_markdown_files(temp_dir.path()).unwrap();
111
112        assert_eq!(files.len(), 2);
113    }
114
115    #[test]
116    fn test_gitignore_respect() {
117        let temp_dir = TempDir::new().unwrap();
118
119        std::process::Command::new("git")
120            .args(["init"])
121            .current_dir(temp_dir.path())
122            .output()
123            .unwrap();
124
125        let ignored_dir = temp_dir.path().join("node_modules");
126        fs::create_dir(&ignored_dir).unwrap();
127
128        let mut gitignore = fs::File::create(temp_dir.path().join(".gitignore")).unwrap();
129        writeln!(gitignore, "node_modules/").unwrap();
130        drop(gitignore);
131
132        fs::File::create(temp_dir.path().join("README.md")).unwrap();
133        fs::File::create(ignored_dir.join("package.md")).unwrap();
134
135        let walker = FileWalker::new(true);
136        let files = walker.find_markdown_files(temp_dir.path()).unwrap();
137
138        assert_eq!(files.len(), 1);
139        assert!(files[0].ends_with("README.md"));
140    }
141
142    #[test]
143    fn test_find_files_with_matcher() {
144        let temp_dir = TempDir::new().unwrap();
145        let docs_dir = temp_dir.path().join("docs");
146        fs::create_dir(&docs_dir).unwrap();
147
148        fs::File::create(temp_dir.path().join("README.md")).unwrap();
149        fs::File::create(docs_dir.join("guide.md")).unwrap();
150        fs::File::create(temp_dir.path().join("CHANGELOG.md")).unwrap();
151
152        let matcher = GlobMatcher::new(&["docs/**/*.md".to_string()]).unwrap();
153        let walker = FileWalker::new(false);
154        let files = walker
155            .find_files_with_matcher(temp_dir.path(), &matcher)
156            .unwrap();
157
158        assert_eq!(files.len(), 1);
159        assert!(files[0].ends_with("docs/guide.md"));
160    }
161
162    #[test]
163    fn test_is_markdown_file() {
164        assert!(is_markdown_file(Path::new("README.md")));
165        assert!(is_markdown_file(Path::new("guide.markdown")));
166        assert!(is_markdown_file(Path::new("doc.mdown")));
167        assert!(is_markdown_file(Path::new("file.mkd")));
168        assert!(!is_markdown_file(Path::new("README.txt")));
169        assert!(!is_markdown_file(Path::new("README")));
170    }
171}