Skip to main content

ktrs_cli/ktlint/
file_utils.rs

1//! Port of ktlint-cli `internal/FileUtils.kt`: patterns (globs with gitignore-style `!` negation, files,
2//! directories) to the files to lint, walked in directory order from the patterns' common parent.
3
4use std::fs;
5use std::time::Instant;
6
7use crate::ktlint::java_glob::PathMatcher;
8use crate::ktlint::jpath::{JPath, ON_WINDOWS};
9use crate::ktlint::logger::{FILE_UTILS, Logger};
10
11const NEGATION_PREFIX: &str = "!";
12const DEFAULT_KOTLIN_FILE_EXTENSIONS: [&str; 2] = ["kt", "kts"];
13pub const DEFAULT_PATTERNS: [&str; 2] = ["**/*.kt", "**/*.kts"];
14
15/// `FileSystem.fileSequence(patterns, rootDir)`: each pattern is a glob, or a file or directory path
16/// relative to `root_dir`, or absolute. `Err` is a `PatternSyntaxException` message (upstream crashes).
17pub fn file_sequence(patterns: &[String], root_dir: &JPath, user_home: &str, logger: &Logger) -> Result<Vec<JPath>, String> {
18    if patterns.is_empty() {
19        logger.trace(FILE_UTILS, || "No patterns provided. Will not expand any globs.".to_owned());
20        return Ok(Vec::new());
21    }
22    let mut result: Vec<JPath> = Vec::new();
23    let (existing_files, patterns_exclusive_existing_files): (Vec<&String>, Vec<&String>) = patterns
24        .iter()
25        .partition(|p| root_dir.resolve(p).is_some_and(|path| path.to_path_buf().is_file()));
26    result.extend(existing_files.iter().filter_map(|p| root_dir.resolve(p)));
27    if !result.is_empty() && patterns_exclusive_existing_files.is_empty() {
28        return Ok(result);
29    }
30
31    let globs = expand(&patterns_exclusive_existing_files, root_dir, user_home, logger);
32    let negated_path_matchers = globs
33        .iter()
34        .filter_map(|g| g.strip_prefix(NEGATION_PREFIX))
35        .map(|g| get_path_matcher(g))
36        .collect::<Result<Vec<_>, _>>()?;
37    let mut include_globs: Vec<String> = globs.iter().filter(|g| !g.starts_with(NEGATION_PREFIX)).cloned().collect();
38    if !negated_path_matchers.is_empty() && include_globs.is_empty() {
39        logger.info(FILE_UTILS, || {
40            format!(
41                "A negate pattern is specified without an include pattern. As default, the include patterns '{}' are used.",
42                java_list(&DEFAULT_PATTERNS)
43            )
44        });
45        let defaults: Vec<String> = DEFAULT_PATTERNS.iter().map(|p| p.to_string()).collect();
46        include_globs.extend(expand(&defaults.iter().collect::<Vec<_>>(), root_dir, user_home, logger));
47    }
48    let mut common_root_dir = root_dir.clone();
49    for pattern in patterns {
50        if let Some(pattern_dir) = root_dir.resolve(pattern) {
51            common_root_dir = find_common_parent_dir(&common_root_dir, &pattern_dir.normalize());
52        }
53    }
54    let path_matchers = include_globs.iter().map(|g| get_path_matcher(g)).collect::<Result<Vec<_>, _>>()?;
55
56    logger.debug(FILE_UTILS, || format!("Start walkFileTree from directory: '{common_root_dir}'"));
57    let start = Instant::now();
58    walk_file_tree(&common_root_dir, &common_root_dir, &mut |path| {
59        if !negated_path_matchers.iter().any(|m| m.matches(path)) && path_matchers.iter().any(|m| m.matches(path)) {
60            logger.trace(FILE_UTILS, || format!("- File: {path}: Include as it matches patterns"));
61            if let Some(path) = JPath::parse(path) {
62                result.push(path);
63            }
64        } else {
65            logger.trace(FILE_UTILS, || format!("- File: {path}: Ignore"));
66        }
67    });
68    let duration = start.elapsed().as_millis();
69    logger.debug(FILE_UTILS, || format!("Discovered {} files to be processed in {duration} ms", result.len()));
70    Ok(result)
71}
72
73/// `Files.walkFileTree` with ktlint's visitor: files (and links, which are not followed) are visited,
74/// hidden directories other than the start are skipped. Listing order is the file system's, like the JVM's.
75fn walk_file_tree(dir: &JPath, start: &JPath, visit_file: &mut dyn FnMut(&str)) {
76    let path = dir.to_path_buf();
77    let Ok(metadata) = fs::symlink_metadata(&path) else { return };
78    if !metadata.is_dir() {
79        visit_file(&dir.to_string());
80        return;
81    }
82    if dir != start && is_hidden(dir, &metadata) {
83        return;
84    }
85    let Ok(entries) = fs::read_dir(&path) else { return };
86    for entry in entries.flatten() {
87        if let Some(child) = dir.resolve(&entry.file_name().to_string_lossy()) {
88            walk_file_tree(&child, start, visit_file);
89        }
90    }
91}
92
93#[cfg(windows)]
94fn is_hidden(_: &JPath, metadata: &fs::Metadata) -> bool {
95    use std::os::windows::fs::MetadataExt;
96    metadata.file_attributes() & 0x2 != 0
97}
98
99#[cfg(not(windows))]
100fn is_hidden(dir: &JPath, _: &fs::Metadata) -> bool {
101    dir.file_name().is_some_and(|n| n.starts_with('.'))
102}
103
104fn get_path_matcher(glob: &str) -> Result<PathMatcher, String> {
105    let glob = glob.strip_prefix("glob:").unwrap_or(glob);
106    PathMatcher::new(glob, ON_WINDOWS)
107}
108
109fn find_common_parent_dir(this: &JPath, path: &JPath) -> JPath {
110    if path.starts_with(this) {
111        this.clone()
112    } else if this.starts_with(path) {
113        path.clone()
114    } else {
115        match path.parent() {
116            Some(parent) => find_common_parent_dir(this, &parent),
117            None => this.clone(),
118        }
119    }
120}
121
122fn expand(patterns: &[&String], root_dir: &JPath, user_home: &str, logger: &Logger) -> Vec<String> {
123    patterns
124        .iter()
125        .filter_map(|p| if ON_WINDOWS { normalize_windows_pattern(p, logger) } else { Some(p.to_string()) })
126        .map(|p| expand_tilde_to_full_path(&p, user_home))
127        .map(|p| if ON_WINDOWS { p.replace('\\', "/") } else { p })
128        .flat_map(|p| to_glob(&p, root_dir))
129        .collect()
130}
131
132fn to_glob(path: &str, root_dir: &JPath) -> Vec<String> {
133    let negation = if path.starts_with(NEGATION_PREFIX) { NEGATION_PREFIX } else { "" };
134    let path_without_negation_prefix = path.strip_prefix(NEGATION_PREFIX).unwrap_or(path);
135    let expanded_patterns = match root_dir.resolve(path_without_negation_prefix) {
136        Some(resolved) => {
137            let resolved_path = resolved.normalize();
138            if resolved_path.to_path_buf().is_dir() {
139                expand_path_to_default_patterns(&resolved_path)
140            } else {
141                expand_double_star_patterns(&resolved_path.to_string())
142            }
143        }
144        None if ON_WINDOWS => expand_double_star_patterns(path_without_negation_prefix),
145        None => Vec::new(),
146    };
147    expanded_patterns
148        .into_iter()
149        .map(|original| {
150            if ON_WINDOWS {
151                let p = original.replace('\\', "/");
152                let p = p.split_once(':').map_or(p.as_str(), |(_, after)| after);
153                let p = p.strip_prefix('/').unwrap_or(p);
154                if p.starts_with("**/") { p.to_owned() } else { format!("**/{p}") }
155            } else {
156                original
157            }
158        })
159        .map(|p| format!("{negation}glob:{p}"))
160        .collect()
161}
162
163/// For each `**` part (except a trailing one), also the path without it, recursively; insertion-ordered set.
164fn expand_double_star_patterns(path: &str) -> Vec<String> {
165    let mut paths = vec![path.to_owned()];
166    let parts: Vec<&str> = path.split('/').collect();
167    for (i, part) in parts.iter().enumerate() {
168        if *part != "**" || i == parts.len() - 1 {
169            continue;
170        }
171        let expanded: Vec<&str> = parts.iter().enumerate().filter(|(j, _)| *j != i).map(|(_, p)| *p).collect();
172        for p in expand_double_star_patterns(&expanded.join("/")) {
173            if !paths.contains(&p) {
174                paths.push(p);
175            }
176        }
177    }
178    paths
179}
180
181fn normalize_windows_pattern(pattern: &str, logger: &Logger) -> Option<String> {
182    let mut parts: Vec<&str> = Vec::new();
183    let replaced = pattern.replace('\\', "/");
184    for part in replaced.split('/').filter(|p| *p != ".") {
185        if part == ".." {
186            match parts.last() {
187                None => {
188                    logger.warn(FILE_UTILS, || {
189                        format!("On WindowsOS the pattern '{pattern}' can not be used as it refers to a path outside of the current directory")
190                    });
191                    return None;
192                }
193                Some(last) if last.contains('*') => {
194                    logger.warn(FILE_UTILS, || {
195                        format!("On WindowsOS the pattern '{pattern}' can not be used as '/..' follows the wildcard pattern {last}")
196                    });
197                    return None;
198                }
199                Some(_) => {
200                    parts.pop();
201                }
202            }
203        } else {
204            parts.push(part);
205        }
206    }
207    Some(parts.join("/"))
208}
209
210fn expand_path_to_default_patterns(path: &JPath) -> Vec<String> {
211    DEFAULT_KOTLIN_FILE_EXTENSIONS.iter().flat_map(|ext| [format!("{path}/*.{ext}"), format!("{path}/**/*.{ext}")]).collect()
212}
213
214/// Replaces a leading `~` (after an optional `!`) with the user's home; not on Windows, where `~` occurs in
215/// short (8.3) path names.
216pub fn expand_tilde_to_full_path(path: &str, user_home: &str) -> String {
217    if ON_WINDOWS {
218        return path.to_owned();
219    }
220    match path.strip_prefix("!~") {
221        Some(rest) => format!("{user_home}{rest}"),
222        None => match path.strip_prefix('~') {
223            Some(rest) => format!("{user_home}{rest}"),
224            None => path.to_owned(),
225        },
226    }
227}
228
229/// `File.location(relative)`: relative to the working directory, or as is; `/`-separated.
230pub fn location(path: &JPath, relative: bool, root_dir_path: &JPath) -> String {
231    if relative { path.relative_to_or_self(root_dir_path).to_string() } else { path.to_string() }
232}
233
234/// Kotlin's `List.toString()`: `[a, b]`.
235pub fn java_list<S: AsRef<str>>(items: &[S]) -> String {
236    let items: Vec<&str> = items.iter().map(AsRef::as_ref).collect();
237    format!("[{}]", items.join(", "))
238}