Skip to main content

zsh/zle/
compmatch_port.rs

1//! Completion matching engine for ZLE
2//!
3//! Port from zsh/Src/Zle/compmatch.c (2,974 lines)
4//!
5//! The full matching engine is in compsys/matching.rs (458 lines).
6//! This module provides the pattern matching, anchor handling, and
7//! match line construction used during completion.
8//!
9//! Key C functions and their Rust locations:
10//! - match_str         → compsys::matching::match_str()
11//! - match_parts       → compsys::matching::match_parts()
12//! - comp_match        → compsys::matching::comp_match()
13//! - pattern_match_equivalence → compsys::matching (inline)
14//! - add_match_str/part/sub    → compsys::matching (inline)
15//! - cline_* (match line ops)  → compsys::base::CompletionLine
16
17/// Completion matcher pattern (from compmatch.c Cmatcher)
18#[derive(Debug, Clone)]
19pub struct CompMatcher {
20    pub line_pattern: String,
21    pub word_pattern: String,
22    pub flags: MatchFlags,
23}
24
25/// Match control flags
26#[derive(Debug, Clone, Copy, Default)]
27pub struct MatchFlags {
28    pub case_insensitive: bool,
29    pub partial_word: bool,
30    pub anchor_start: bool,
31    pub anchor_end: bool,
32    pub substring: bool,
33}
34
35/// A completion line segment (from compmatch.c Cline)
36#[derive(Debug, Clone)]
37pub struct CompLine {
38    pub prefix: String,
39    pub line: String,
40    pub suffix: String,
41    pub word: String,
42    pub matched: bool,
43}
44
45impl CompLine {
46    pub fn new() -> Self {
47        CompLine {
48            prefix: String::new(),
49            line: String::new(),
50            suffix: String::new(),
51            word: String::new(),
52            matched: false,
53        }
54    }
55
56    /// Get the total length (from compmatch.c cline_sublen)
57    pub fn sublen(&self) -> usize {
58        self.prefix.len() + self.line.len() + self.suffix.len()
59    }
60
61    /// Set lengths from content (from compmatch.c cline_setlens)
62    pub fn setlens(&mut self) {
63        // Already handled by String lengths
64    }
65}
66
67impl Default for CompLine {
68    fn default() -> Self {
69        Self::new()
70    }
71}
72
73/// Check if two matcher patterns are the same (from compmatch.c cpatterns_same)
74pub fn cpatterns_same(a: &[CompMatcher], b: &[CompMatcher]) -> bool {
75    if a.len() != b.len() {
76        return false;
77    }
78    a.iter()
79        .zip(b.iter())
80        .all(|(ma, mb)| ma.line_pattern == mb.line_pattern && ma.word_pattern == mb.word_pattern)
81}
82
83/// Check if two matcher lists are the same (from compmatch.c cmatchers_same)
84pub fn cmatchers_same(a: &[CompMatcher], b: &[CompMatcher]) -> bool {
85    cpatterns_same(a, b)
86}
87
88/// Match a completion word against a line (from compmatch.c match_str)
89pub fn match_str(
90    line: &str,
91    word: &str,
92    matchers: &[CompMatcher],
93    flags: &MatchFlags,
94) -> Option<Vec<CompLine>> {
95    if flags.case_insensitive {
96        if line.to_lowercase().starts_with(&word.to_lowercase()) {
97            return Some(vec![CompLine {
98                line: line.to_string(),
99                word: word.to_string(),
100                matched: true,
101                ..Default::default()
102            }]);
103        }
104    } else if line.starts_with(word) {
105        return Some(vec![CompLine {
106            line: line.to_string(),
107            word: word.to_string(),
108            matched: true,
109            ..Default::default()
110        }]);
111    }
112
113    // Try matchers
114    for matcher in matchers {
115        if try_matcher(line, word, matcher) {
116            return Some(vec![CompLine {
117                line: line.to_string(),
118                word: word.to_string(),
119                matched: true,
120                ..Default::default()
121            }]);
122        }
123    }
124
125    None
126}
127
128fn try_matcher(line: &str, word: &str, matcher: &CompMatcher) -> bool {
129    if matcher.flags.case_insensitive {
130        line.to_lowercase().contains(&word.to_lowercase())
131    } else if matcher.flags.substring {
132        line.contains(word)
133    } else if matcher.flags.partial_word {
134        // Match word parts: "fb" matches "foobar" at word boundaries
135        let mut li = line.chars().peekable();
136        let mut wi = word.chars();
137        let mut wc = wi.next();
138
139        while let Some(lc) = li.next() {
140            if let Some(w) = wc {
141                if lc.eq_ignore_ascii_case(&w) {
142                    wc = wi.next();
143                }
144            } else {
145                return true;
146            }
147        }
148        wc.is_none()
149    } else {
150        false
151    }
152}
153
154/// Match parts of a completion (from compmatch.c match_parts)
155pub fn match_parts(line: &str, word: &str, flags: &MatchFlags) -> Vec<(usize, usize)> {
156    let mut parts = Vec::new();
157    let line_lower = if flags.case_insensitive {
158        line.to_lowercase()
159    } else {
160        line.to_string()
161    };
162    let word_lower = if flags.case_insensitive {
163        word.to_lowercase()
164    } else {
165        word.to_string()
166    };
167
168    let mut pos = 0;
169    for wc in word_lower.chars() {
170        if let Some(found) = line_lower[pos..].find(wc) {
171            let abs_pos = pos + found;
172            parts.push((abs_pos, abs_pos + wc.len_utf8()));
173            pos = abs_pos + wc.len_utf8();
174        }
175    }
176    parts
177}
178
179/// Full completion match (from compmatch.c comp_match)
180pub fn comp_match(line: &str, word: &str, flags: &MatchFlags) -> bool {
181    match_str(line, word, &[], flags).is_some()
182}
183
184/// Start a match operation (from compmatch.c start_match)
185pub fn start_match() -> Vec<CompLine> {
186    Vec::new()
187}
188
189/// Abort a match operation (from compmatch.c abort_match)
190pub fn abort_match(_lines: Vec<CompLine>) {
191    // Drop the lines
192}
193
194/// Get a CompLine copy (from compmatch.c get_cline/cp_cline)
195pub fn cp_cline(line: &CompLine) -> CompLine {
196    line.clone()
197}
198
199/// Free a CompLine (from compmatch.c free_cline) - no-op in Rust
200pub fn free_cline(_line: CompLine) {}
201
202/// Revert a CompLine to original state (from compmatch.c revert_cline)
203pub fn revert_cline(line: &mut CompLine) {
204    line.matched = false;
205}
206
207/// Check if a CompLine was matched (from compmatch.c cline_matched)
208pub fn cline_matched(line: &CompLine) -> bool {
209    line.matched
210}
211
212/// Pattern match with equivalence classes (from compmatch.c pattern_match_equivalence)
213pub fn pattern_match_equivalence(a: char, b: char, case_insensitive: bool) -> bool {
214    if case_insensitive {
215        a.eq_ignore_ascii_case(&b)
216    } else {
217        a == b
218    }
219}
220
221/// Parse a matcher specification string (from compmatch.c)
222/// Format: `m:{[:lower:]}={[:upper:]}` or `l:|=* r:|=*` etc.
223pub fn parse_matcher_spec(spec: &str) -> Vec<CompMatcher> {
224    let mut matchers = Vec::new();
225
226    for part in spec.split_whitespace() {
227        let flags = MatchFlags {
228            case_insensitive: part.starts_with("m:"),
229            partial_word: part.starts_with("r:") || part.starts_with("l:"),
230            anchor_start: part.starts_with("l:"),
231            anchor_end: part.starts_with("r:"),
232            substring: part.starts_with("M:"),
233        };
234
235        if let Some((line_pat, word_pat)) = part.split_once('=') {
236            let line_pat = line_pat.split(':').last().unwrap_or("");
237            matchers.push(CompMatcher {
238                line_pattern: line_pat.to_string(),
239                word_pattern: word_pat.to_string(),
240                flags,
241            });
242        }
243    }
244
245    matchers
246}
247
248/// Update bmatchers (from compmatch.c add_bmatchers/update_bmatchers)
249pub fn update_bmatchers(matchers: &mut Vec<CompMatcher>, new: Vec<CompMatcher>) {
250    *matchers = new;
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256
257    #[test]
258    fn test_match_str_exact() {
259        let flags = MatchFlags::default();
260        assert!(match_str("foobar", "foo", &[], &flags).is_some());
261        assert!(match_str("foobar", "baz", &[], &flags).is_none());
262    }
263
264    #[test]
265    fn test_match_str_case_insensitive() {
266        let flags = MatchFlags {
267            case_insensitive: true,
268            ..Default::default()
269        };
270        assert!(match_str("FooBar", "foo", &[], &flags).is_some());
271    }
272
273    #[test]
274    fn test_match_parts() {
275        let flags = MatchFlags::default();
276        let parts = match_parts("foobar", "fbr", &flags);
277        assert_eq!(parts.len(), 3);
278    }
279
280    #[test]
281    fn test_pattern_match_equivalence() {
282        assert!(pattern_match_equivalence('a', 'A', true));
283        assert!(!pattern_match_equivalence('a', 'A', false));
284    }
285
286    #[test]
287    fn test_parse_matcher_spec() {
288        let matchers = parse_matcher_spec("m:{[:lower:]}={[:upper:]}");
289        assert_eq!(matchers.len(), 1);
290        assert!(matchers[0].flags.case_insensitive);
291    }
292
293    #[test]
294    fn test_comp_line() {
295        let mut cl = CompLine::new();
296        cl.prefix = "pre".to_string();
297        cl.line = "middle".to_string();
298        cl.suffix = "suf".to_string();
299        assert_eq!(cl.sublen(), 12);
300    }
301}