Skip to main content

zsh/compsys/ported/
shared.rs

1//! Tiny helpers shared between per-fn ports. Kept here (not in
2//! library.rs) so the `ported/` tree stands alone.
3
4use std::path::Path;
5/// `is_executable` — see implementation.
6pub fn is_executable(path: &Path) -> bool {
7    #[cfg(unix)]
8    {
9        use std::os::unix::fs::PermissionsExt;
10        if let Ok(meta) = path.metadata() {
11            let mode = meta.permissions().mode();
12            return mode & 0o111 != 0;
13        }
14    }
15    #[cfg(not(unix))]
16    {
17        if let Some(ext) = path.extension() {
18            let ext = ext.to_string_lossy().to_lowercase();
19            return matches!(ext.as_str(), "exe" | "bat" | "cmd" | "com");
20        }
21    }
22    false
23}
24
25/// Shell-glob matcher — supports `*`, `?`, and `(a|b|c)`
26/// alternation (zsh extended-glob's `(…|…)` form). Sufficient for
27/// the patterns end-user completion files use (e.g.
28/// `*.(md|rs|toml)` from `_suffix_alias_files`).
29pub fn glob_matches(pattern: &str, text: &str) -> bool {
30    // Handle leading `(alt1|alt2|…)` at the top level — split at the
31    // matching close paren, try each alternative concatenated with
32    // the remainder.
33    if let Some(rest) = pattern.strip_prefix('(') {
34        if let Some(close) = find_top_close_paren(rest) {
35            let group = &rest[..close];
36            let after = &rest[close + 1..];
37            return group.split('|').any(|alt| {
38                let combined = format!("{}{}", alt, after);
39                glob_matches(&combined, text)
40            });
41        }
42    }
43    let pat: Vec<char> = pattern.chars().collect();
44    let txt: Vec<char> = text.chars().collect();
45    glob_helper(&pat, &txt)
46}
47
48fn find_top_close_paren(s: &str) -> Option<usize> {
49    let mut depth: i32 = 1;
50    for (i, c) in s.char_indices() {
51        match c {
52            '(' => depth += 1,
53            ')' => {
54                depth -= 1;
55                if depth == 0 {
56                    return Some(i);
57                }
58            }
59            _ => {}
60        }
61    }
62    None
63}
64
65fn glob_helper(pat: &[char], txt: &[char]) -> bool {
66    if pat.is_empty() {
67        return txt.is_empty();
68    }
69    // Inline alternation at any position: when we encounter `(...)`,
70    // re-route through `glob_matches` on the remainder.
71    if pat[0] == '(' {
72        let rest: String = pat[1..].iter().collect();
73        let txt_str: String = txt.iter().collect();
74        if let Some(close) = find_top_close_paren(&rest) {
75            let group = &rest[..close];
76            let after = &rest[close + 1..];
77            return group.split('|').any(|alt| {
78                let combined = format!("{}{}", alt, after);
79                glob_matches(&combined, &txt_str)
80            });
81        }
82    }
83    match pat[0] {
84        '*' => {
85            for i in 0..=txt.len() {
86                if glob_helper(&pat[1..], &txt[i..]) {
87                    return true;
88                }
89            }
90            false
91        }
92        '?' => !txt.is_empty() && glob_helper(&pat[1..], &txt[1..]),
93        c => !txt.is_empty() && txt[0] == c && glob_helper(&pat[1..], &txt[1..]),
94    }
95}
96
97/// Shell-glob matcher mirror of the helper that used to live in
98/// `compsys/functions.rs` — kept as a separate symbol because callers
99/// were spelled `functions::glob_match(...)`, distinct from
100/// `glob_matches` above (which the `library.rs`/`ported/_path_files`
101/// code used). Both share semantics; the duplicate is intentional for
102/// API-shape compat with both call-site ()/* styles */.
103pub fn glob_match(pattern: &str, text: &str) -> bool {
104    glob_matches(pattern, text)
105}
106
107/// Levenshtein edit distance, used by `_approximate`, `_correct`,
108/// `_correct_filename`, and `_correct_word`. Moved out of
109/// `compsys/functions.rs` so it can be shared across the per-fn ports
110/// without introducing a circular dependency between them.
111pub fn edit_distance(a: &str, b: &str) -> usize {
112    let a_chars: Vec<char> = a.chars().collect();
113    let b_chars: Vec<char> = b.chars().collect();
114    let m = a_chars.len();
115    let n = b_chars.len();
116
117    let mut dp = vec![vec![0; n + 1]; m + 1];
118
119    // Levenshtein DP base row/col init — needless_range_loop trips here
120    // but the index IS the value being written, not a positional access.
121    #[allow(clippy::needless_range_loop)]
122    for i in 0..=m {
123        dp[i][0] = i;
124    }
125    #[allow(clippy::needless_range_loop)]
126    for j in 0..=n {
127        dp[0][j] = j;
128    }
129
130    for i in 1..=m {
131        for j in 1..=n {
132            let cost = if a_chars[i - 1] == b_chars[j - 1] {
133                0
134            } else {
135                1
136            };
137            dp[i][j] = (dp[i - 1][j] + 1)
138                .min(dp[i][j - 1] + 1)
139                .min(dp[i - 1][j - 1] + cost);
140        }
141    }
142
143    dp[m][n]
144}
145
146/// Check if a string matches any ignored pattern. Extracted from
147/// `compsys/base.rs::is_ignored`. Uses the same `glob_match` helper
148/// as the rest of the per-fn ports.
149pub fn is_ignored(s: &str, patterns: &[String]) -> bool {
150    for pattern in patterns {
151        if glob_match(pattern, s) {
152            return true;
153        }
154    }
155    false
156}
157
158/// `get_ignored_patterns(context)` — collect `ignored-patterns`
159/// zstyle values for `context` via the real `lookupstyle` in
160/// `src/ported/modules/zutil.rs`.
161pub fn get_ignored_patterns(context: &str) -> Vec<String> {
162    crate::ported::modules::zutil::lookupstyle(context, "ignored-patterns")
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168
169    // glob_match coverage migrated from `compsys/base.rs` when the
170    // local glob_match helper there was removed in favor of this
171    // single shared implementation.
172
173    #[test]
174    fn test_glob_match_simple() {
175        assert!(glob_match("*.txt", "file.txt"));
176        assert!(glob_match("*.txt", ".txt"));
177        assert!(!glob_match("*.txt", "file.rs"));
178    }
179
180    #[test]
181    fn test_glob_match_question() {
182        assert!(glob_match("file?.txt", "file1.txt"));
183        assert!(glob_match("file?.txt", "fileX.txt"));
184        assert!(!glob_match("file?.txt", "file.txt"));
185        assert!(!glob_match("file?.txt", "file12.txt"));
186    }
187
188    #[test]
189    fn test_glob_match_star_middle() {
190        assert!(glob_match("foo*bar", "foobar"));
191        assert!(glob_match("foo*bar", "foo123bar"));
192        assert!(glob_match("foo*bar", "fooXYZbar"));
193        assert!(!glob_match("foo*bar", "foobaz"));
194    }
195
196    #[test]
197    fn test_glob_match_multiple_stars() {
198        assert!(glob_match("*foo*", "foo"));
199        assert!(glob_match("*foo*", "afoo"));
200        assert!(glob_match("*foo*", "foob"));
201        assert!(glob_match("*foo*", "afoob"));
202        assert!(!glob_match("*foo*", "bar"));
203    }
204
205    #[test]
206    fn test_glob_match_exact() {
207        assert!(glob_match("exact", "exact"));
208        assert!(!glob_match("exact", "exacty"));
209        assert!(!glob_match("exact", "xact"));
210    }
211}