Skip to main content

flag_rs/
completion_format.rs

1//! Completion format handling for different shells
2//!
3//! This module defines how completions are formatted for different shells,
4//! including support for descriptions where the shell supports them.
5
6use crate::active_help::ActiveHelp;
7use crate::completion::CompletionResult;
8use crate::context::Context;
9
10/// Represents the format in which completions should be returned
11#[derive(Debug, Clone, Copy)]
12pub enum CompletionFormat {
13    /// Simple list of values (for basic shells)
14    Simple,
15    /// Values with descriptions for display (not for shell consumption)
16    Display,
17    /// Zsh format with descriptions
18    Zsh,
19    /// Fish format with descriptions
20    Fish,
21    /// Bash format (requires special handling)
22    Bash,
23}
24
25impl CompletionFormat {
26    /// Detects the format from the shell type string
27    pub fn from_shell_type(shell_type: Option<&str>) -> Self {
28        match shell_type {
29            Some("zsh") => Self::Zsh,
30            Some("fish") => Self::Fish,
31            Some("bash") => Self::Bash,
32            Some("display") => Self::Display,
33            _ => Self::Simple,
34        }
35    }
36
37    /// Formats a completion result according to this format
38    pub fn format(self, result: &CompletionResult, ctx: Option<&Context>) -> Vec<String> {
39        let mut output = match self {
40            Self::Simple | Self::Bash => result.values.clone(),
41            Self::Display => Self::format_display(result),
42            Self::Zsh => {
43                // Zsh has special syntax for descriptions
44                Self::format_zsh(result)
45            }
46            Self::Fish => {
47                // Fish uses tab-separated format
48                Self::format_fish(result)
49            }
50        };
51
52        if let Some(ctx) = ctx {
53            let help_messages = Self::format_active_help(&result.active_help, ctx, self);
54            output.extend(help_messages);
55        }
56
57        output
58    }
59
60    /// Formats for human-readable display (not shell consumption)
61    fn format_display(result: &CompletionResult) -> Vec<String> {
62        use crate::color;
63
64        let has_descriptions = result.descriptions.iter().any(|d| !d.is_empty());
65        if !has_descriptions {
66            return result.values.clone();
67        }
68
69        // Calculate column width
70        let max_width = result.values.iter().map(String::len).max().unwrap_or(0);
71        let column_width = max_width + 4;
72
73        result
74            .values
75            .iter()
76            .zip(&result.descriptions)
77            .map(|(value, desc)| {
78                if desc.is_empty() {
79                    value.clone()
80                } else {
81                    let padded = format!("{value:<column_width$}");
82                    if color::should_colorize() {
83                        format!("{padded}{}", color::dim(desc))
84                    } else {
85                        format!("{padded}{desc}")
86                    }
87                }
88            })
89            .collect()
90    }
91
92    /// Formats for Zsh completion
93    fn format_zsh(result: &CompletionResult) -> Vec<String> {
94        // Terminal width constraint
95        const MAX_WIDTH: usize = 80;
96
97        // Calculate max width for alignment, but cap it
98        let max_value_width = result.values.iter().map(String::len).max().unwrap_or(0);
99        // Limit padding to ensure we don't exceed terminal width
100        // Reserve space for ": - " (4 chars) and some description text
101        let padding = max_value_width.min(35) + 4;
102
103        result
104            .values
105            .iter()
106            .zip(&result.descriptions)
107            .map(|(value, desc)| {
108                // We need to escape colons in the value
109                let escaped_value = value.replace(':', "\\:");
110
111                if desc.is_empty() {
112                    // Even without description, use the standard format for zsh compatibility
113                    format!("{escaped_value}:{escaped_value}    - ")
114                } else {
115                    // Zsh format: value:description
116                    // Format with padding
117                    let formatted_desc = if value.len() <= 35 {
118                        format!("{escaped_value:<padding$}- {desc}")
119                    } else {
120                        // For very long values, skip padding
121                        format!("{escaped_value} - {desc}")
122                    };
123
124                    // Truncate if still too long
125                    let full_line = format!("{escaped_value}:{formatted_desc}");
126                    if full_line.len() > MAX_WIDTH {
127                        format!("{}...", char_safe_prefix(&full_line, MAX_WIDTH - 3))
128                    } else {
129                        full_line
130                    }
131                }
132            })
133            .collect()
134    }
135
136    /// Formats for Fish completion
137    fn format_fish(result: &CompletionResult) -> Vec<String> {
138        // Terminal width constraint
139        const MAX_WIDTH: usize = 80;
140
141        // Calculate max width for alignment, but cap it
142        let max_value_width = result.values.iter().map(String::len).max().unwrap_or(0);
143        // Limit padding to ensure we don't exceed terminal width
144        let padding = max_value_width.min(35) + 4;
145
146        result
147            .values
148            .iter()
149            .zip(&result.descriptions)
150            .map(|(value, desc)| {
151                if desc.is_empty() {
152                    // For fish, just the value is fine without description
153                    value.clone()
154                } else {
155                    // Fish format: value\tdescription
156                    // Format with padding
157                    let formatted_desc = if value.len() <= 35 {
158                        format!("{value:<padding$}- {desc}")
159                    } else {
160                        // For very long values, skip padding
161                        format!("{value} - {desc}")
162                    };
163
164                    // Fish uses tab separation, but still check total length
165                    let full_line = format!("{value}\t{formatted_desc}");
166                    if formatted_desc.len() > MAX_WIDTH {
167                        let truncated_desc =
168                            format!("{}...", char_safe_prefix(&formatted_desc, MAX_WIDTH - 3));
169                        format!("{value}\t{truncated_desc}")
170                    } else {
171                        full_line
172                    }
173                }
174            })
175            .collect()
176    }
177
178    /// Formats `ActiveHelp` messages for the given shell
179    fn format_active_help(
180        help_messages: &[ActiveHelp],
181        ctx: &Context,
182        format: Self,
183    ) -> Vec<String> {
184        let mut formatted = Vec::new();
185
186        for help in help_messages {
187            if help.should_display(ctx) {
188                match format {
189                    Self::Bash => {
190                        // Bash: ActiveHelp messages are prefixed with a special marker
191                        // that completion scripts can recognize and display differently
192                        formatted.push(format!("_activehelp_ {}", help.message));
193                    }
194                    Self::Zsh => {
195                        // Zsh: Use a special format that won't be selectable
196                        // The completion script should recognize this pattern
197                        formatted.push(format!("_activehelp_::{}", help.message));
198                    }
199                    Self::Fish => {
200                        // Fish: Similar to Zsh, use a special prefix
201                        formatted.push(format!("_activehelp_\t{}", help.message));
202                    }
203                    Self::Simple | Self::Display => {
204                        // For simple/display format, just show the message with a prefix
205                        formatted.push(format!("[HELP] {}", help.message));
206                    }
207                }
208            }
209        }
210
211        formatted
212    }
213}
214
215/// Returns the longest prefix of `s` that fits in `max_bytes` and ends on a
216/// UTF-8 char boundary. The shell-format truncators above use this to avoid
217/// panicking when a multi-byte char straddles the byte cutoff.
218fn char_safe_prefix(s: &str, max_bytes: usize) -> &str {
219    let mut end = 0;
220    for (i, c) in s.char_indices() {
221        let next = i + c.len_utf8();
222        if next > max_bytes {
223            break;
224        }
225        end = next;
226    }
227    &s[..end]
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233    use crate::completion::CompletionResult;
234
235    #[test]
236    fn test_zsh_format_with_empty_description() {
237        let result = CompletionResult::new()
238            .add("value-without-desc")
239            .add_with_description("value-with-desc", "This has a description");
240
241        let formatted = CompletionFormat::Zsh.format(&result, None);
242
243        // Empty descriptions should still produce proper zsh format
244        assert_eq!(formatted.len(), 2);
245        assert!(formatted[0].starts_with("value-without-desc:"));
246        assert!(formatted[0].contains(" - "));
247        assert!(formatted[1].starts_with("value-with-desc:"));
248    }
249
250    #[test]
251    fn test_zsh_format_uuid_without_description() {
252        // Test case that caused the invisible completion bug
253        let result = CompletionResult::new().add("28cbc1d1-7750-4253-9f55-ae21b9156b9d");
254
255        let formatted = CompletionFormat::Zsh.format(&result, None);
256
257        assert_eq!(formatted.len(), 1);
258        // Must have the zsh format even without description
259        assert!(formatted[0].contains(':'));
260        assert!(formatted[0].contains(" - "));
261        // Check exact format
262        assert_eq!(
263            formatted[0],
264            "28cbc1d1-7750-4253-9f55-ae21b9156b9d:28cbc1d1-7750-4253-9f55-ae21b9156b9d    - "
265        );
266    }
267
268    #[test]
269    fn test_empty_value_handling() {
270        let result = CompletionResult::new()
271            .add("")
272            .add_with_description("", "Empty value with description");
273
274        let formatted = CompletionFormat::Zsh.format(&result, None);
275
276        // Even empty values should be formatted properly
277        assert_eq!(formatted.len(), 2);
278        for line in &formatted {
279            assert!(line.contains(':'));
280        }
281    }
282
283    #[test]
284    fn test_special_characters_in_value() {
285        let result = CompletionResult::new()
286            .add("value:with:colons")
287            .add("value'with'quotes")
288            .add("value with spaces");
289
290        let formatted = CompletionFormat::Zsh.format(&result, None);
291
292        // Colons should be escaped
293        assert!(formatted[0].starts_with("value\\:with\\:colons:"));
294        // All values should be properly formatted
295        assert_eq!(formatted.len(), 3);
296        for line in &formatted {
297            assert!(line.contains(" - "));
298        }
299    }
300
301    #[test]
302    fn test_fish_format_empty_description() {
303        let result = CompletionResult::new()
304            .add("no-desc-value")
305            .add_with_description("with-desc", "Description");
306
307        let formatted = CompletionFormat::Fish.format(&result, None);
308
309        // Fish can have values without descriptions
310        assert_eq!(formatted[0], "no-desc-value");
311        assert!(formatted[1].contains('\t'));
312    }
313
314    #[test]
315    fn test_bash_format() {
316        let result = CompletionResult::new()
317            .add("value1")
318            .add_with_description("value2", "Description ignored for bash");
319
320        let formatted = CompletionFormat::Bash.format(&result, None);
321
322        // Bash format is just the values
323        assert_eq!(formatted, vec!["value1", "value2"]);
324    }
325
326    #[test]
327    fn test_line_length_limits() {
328        let long_value = "a".repeat(50);
329        let long_desc = "b".repeat(50);
330
331        let result = CompletionResult::new().add_with_description(&long_value, &long_desc);
332
333        let formatted = CompletionFormat::Zsh.format(&result, None);
334
335        // All lines should be <= 80 characters
336        for line in formatted {
337            assert!(line.len() <= 80, "Line too long: {} chars", line.len());
338            if line.len() == 80 {
339                assert!(
340                    line.ends_with("..."),
341                    "Long lines should be truncated with ..."
342                );
343            }
344        }
345    }
346
347    #[test]
348    fn test_active_help_formatting() {
349        let result = CompletionResult::new()
350            .add("option1")
351            .add_help_text("This is a help message")
352            .add_conditional_help("Conditional help", |_| true)
353            .add_conditional_help("Hidden help", |_| false);
354
355        let ctx = Context::new(vec![]);
356
357        // Test Bash format
358        let bash_formatted = CompletionFormat::Bash.format(&result, Some(&ctx));
359        assert!(bash_formatted.contains(&"option1".to_string()));
360        assert!(bash_formatted.contains(&"_activehelp_ This is a help message".to_string()));
361        assert!(bash_formatted.contains(&"_activehelp_ Conditional help".to_string()));
362        assert!(!bash_formatted.iter().any(|s| s.contains("Hidden help")));
363
364        // Test Zsh format
365        let zsh_formatted = CompletionFormat::Zsh.format(&result, Some(&ctx));
366        assert!(
367            zsh_formatted
368                .iter()
369                .any(|s| s.contains("_activehelp_::This is a help message"))
370        );
371        assert!(
372            zsh_formatted
373                .iter()
374                .any(|s| s.contains("_activehelp_::Conditional help"))
375        );
376
377        // Test Fish format
378        let fish_formatted = CompletionFormat::Fish.format(&result, Some(&ctx));
379        assert!(fish_formatted.contains(&"option1".to_string()));
380        assert!(fish_formatted.contains(&"_activehelp_\tThis is a help message".to_string()));
381        assert!(fish_formatted.contains(&"_activehelp_\tConditional help".to_string()));
382
383        // Test without context - no ActiveHelp should be shown
384        let no_ctx_formatted = CompletionFormat::Bash.format(&result, None);
385        assert!(!no_ctx_formatted.iter().any(|s| s.contains("_activehelp_")));
386    }
387
388    #[test]
389    fn test_zsh_truncation_handles_multibyte_utf8() {
390        // Regression: the old `&full_line[..MAX_WIDTH - 3]` byte slice panicked
391        // when byte 77 of the formatted line fell inside a multi-byte UTF-8
392        // char. Construct an input that places '★' (3 bytes) at full_line
393        // bytes 75-77.
394        let value = "v";
395        let desc = "a".repeat(66) + "★ tail";
396        let result = CompletionResult::new().add_with_description(value, &desc);
397
398        let out = CompletionFormat::Zsh.format(&result, None);
399        assert_eq!(out.len(), 1);
400        assert!(
401            out[0].ends_with("..."),
402            "expected truncation marker, got: {}",
403            out[0]
404        );
405    }
406
407    #[test]
408    fn test_fish_truncation_handles_multibyte_utf8() {
409        // Same regression on the fish path, which truncates `formatted_desc`
410        // (not `full_line`); byte offsets land slightly differently.
411        let value = "v";
412        let desc = "a".repeat(68) + "★ tail";
413        let result = CompletionResult::new().add_with_description(value, &desc);
414
415        let out = CompletionFormat::Fish.format(&result, None);
416        assert_eq!(out.len(), 1);
417        assert!(
418            out[0].contains("..."),
419            "expected truncation marker, got: {}",
420            out[0]
421        );
422    }
423}