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}