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 => {
41                // For bash and simple format, return just the values
42                result.values.clone()
43            }
44            Self::Display => {
45                // For display, show formatted with descriptions
46                Self::format_display(result)
47            }
48            Self::Zsh => {
49                // Zsh has special syntax for descriptions
50                Self::format_zsh(result)
51            }
52            Self::Fish => {
53                // Fish uses tab-separated format
54                Self::format_fish(result)
55            }
56        };
57
58        // Add ActiveHelp messages if any (and context is provided)
59        if let Some(ctx) = ctx {
60            let help_messages = Self::format_active_help(&result.active_help, ctx, self);
61            output.extend(help_messages);
62        }
63
64        output
65    }
66
67    /// Formats for human-readable display (not shell consumption)
68    fn format_display(result: &CompletionResult) -> Vec<String> {
69        use crate::color;
70
71        let has_descriptions = result.descriptions.iter().any(|d| !d.is_empty());
72        if !has_descriptions {
73            return result.values.clone();
74        }
75
76        // Calculate column width
77        let max_width = result.values.iter().map(String::len).max().unwrap_or(0);
78        let column_width = max_width + 4;
79
80        result
81            .values
82            .iter()
83            .zip(&result.descriptions)
84            .map(|(value, desc)| {
85                if desc.is_empty() {
86                    value.clone()
87                } else {
88                    let padded = format!("{value:<column_width$}");
89                    if color::should_colorize() {
90                        format!("{padded}{}", color::dim(desc))
91                    } else {
92                        format!("{padded}{desc}")
93                    }
94                }
95            })
96            .collect()
97    }
98
99    /// Formats for Zsh completion
100    fn format_zsh(result: &CompletionResult) -> Vec<String> {
101        // Terminal width constraint
102        const MAX_WIDTH: usize = 80;
103
104        // Calculate max width for alignment, but cap it
105        let max_value_width = result.values.iter().map(String::len).max().unwrap_or(0);
106        // Limit padding to ensure we don't exceed terminal width
107        // Reserve space for ": - " (4 chars) and some description text
108        let padding = max_value_width.min(35) + 4;
109
110        result
111            .values
112            .iter()
113            .zip(&result.descriptions)
114            .map(|(value, desc)| {
115                // We need to escape colons in the value
116                let escaped_value = value.replace(':', "\\:");
117
118                if desc.is_empty() {
119                    // Even without description, use the standard format for zsh compatibility
120                    format!("{escaped_value}:{escaped_value}    - ")
121                } else {
122                    // Zsh format: value:description
123                    // Format with padding
124                    let formatted_desc = if value.len() <= 35 {
125                        format!("{escaped_value:<padding$}- {desc}")
126                    } else {
127                        // For very long values, skip padding
128                        format!("{escaped_value} - {desc}")
129                    };
130
131                    // Truncate if still too long
132                    let full_line = format!("{escaped_value}:{formatted_desc}");
133                    if full_line.len() > MAX_WIDTH {
134                        format!("{}...", &full_line[..MAX_WIDTH - 3])
135                    } else {
136                        full_line
137                    }
138                }
139            })
140            .collect()
141    }
142
143    /// Formats for Fish completion
144    fn format_fish(result: &CompletionResult) -> Vec<String> {
145        // Terminal width constraint
146        const MAX_WIDTH: usize = 80;
147
148        // Calculate max width for alignment, but cap it
149        let max_value_width = result.values.iter().map(String::len).max().unwrap_or(0);
150        // Limit padding to ensure we don't exceed terminal width
151        let padding = max_value_width.min(35) + 4;
152
153        result
154            .values
155            .iter()
156            .zip(&result.descriptions)
157            .map(|(value, desc)| {
158                if desc.is_empty() {
159                    // For fish, just the value is fine without description
160                    value.clone()
161                } else {
162                    // Fish format: value\tdescription
163                    // Format with padding
164                    let formatted_desc = if value.len() <= 35 {
165                        format!("{value:<padding$}- {desc}")
166                    } else {
167                        // For very long values, skip padding
168                        format!("{value} - {desc}")
169                    };
170
171                    // Fish uses tab separation, but still check total length
172                    let full_line = format!("{value}\t{formatted_desc}");
173                    if formatted_desc.len() > MAX_WIDTH {
174                        let truncated_desc = format!("{}...", &formatted_desc[..MAX_WIDTH - 3]);
175                        format!("{value}\t{truncated_desc}")
176                    } else {
177                        full_line
178                    }
179                }
180            })
181            .collect()
182    }
183
184    /// Formats `ActiveHelp` messages for the given shell
185    fn format_active_help(
186        help_messages: &[ActiveHelp],
187        ctx: &Context,
188        format: Self,
189    ) -> Vec<String> {
190        let mut formatted = Vec::new();
191
192        for help in help_messages {
193            if help.should_display(ctx) {
194                match format {
195                    Self::Bash => {
196                        // Bash: ActiveHelp messages are prefixed with a special marker
197                        // that completion scripts can recognize and display differently
198                        formatted.push(format!("_activehelp_ {}", help.message));
199                    }
200                    Self::Zsh => {
201                        // Zsh: Use a special format that won't be selectable
202                        // The completion script should recognize this pattern
203                        formatted.push(format!("_activehelp_::{}", help.message));
204                    }
205                    Self::Fish => {
206                        // Fish: Similar to Zsh, use a special prefix
207                        formatted.push(format!("_activehelp_\t{}", help.message));
208                    }
209                    Self::Simple | Self::Display => {
210                        // For simple/display format, just show the message with a prefix
211                        formatted.push(format!("[HELP] {}", help.message));
212                    }
213                }
214            }
215        }
216
217        formatted
218    }
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224    use crate::completion::CompletionResult;
225
226    #[test]
227    fn test_zsh_format_with_empty_description() {
228        let result = CompletionResult::new()
229            .add("value-without-desc")
230            .add_with_description("value-with-desc", "This has a description");
231
232        let formatted = CompletionFormat::Zsh.format(&result, None);
233
234        // Empty descriptions should still produce proper zsh format
235        assert_eq!(formatted.len(), 2);
236        assert!(formatted[0].starts_with("value-without-desc:"));
237        assert!(formatted[0].contains(" - "));
238        assert!(formatted[1].starts_with("value-with-desc:"));
239    }
240
241    #[test]
242    fn test_zsh_format_uuid_without_description() {
243        // Test case that caused the invisible completion bug
244        let result = CompletionResult::new().add("28cbc1d1-7750-4253-9f55-ae21b9156b9d");
245
246        let formatted = CompletionFormat::Zsh.format(&result, None);
247
248        assert_eq!(formatted.len(), 1);
249        // Must have the zsh format even without description
250        assert!(formatted[0].contains(':'));
251        assert!(formatted[0].contains(" - "));
252        // Check exact format
253        assert_eq!(
254            formatted[0],
255            "28cbc1d1-7750-4253-9f55-ae21b9156b9d:28cbc1d1-7750-4253-9f55-ae21b9156b9d    - "
256        );
257    }
258
259    #[test]
260    fn test_empty_value_handling() {
261        let result = CompletionResult::new()
262            .add("")
263            .add_with_description("", "Empty value with description");
264
265        let formatted = CompletionFormat::Zsh.format(&result, None);
266
267        // Even empty values should be formatted properly
268        assert_eq!(formatted.len(), 2);
269        for line in &formatted {
270            assert!(line.contains(':'));
271        }
272    }
273
274    #[test]
275    fn test_special_characters_in_value() {
276        let result = CompletionResult::new()
277            .add("value:with:colons")
278            .add("value'with'quotes")
279            .add("value with spaces");
280
281        let formatted = CompletionFormat::Zsh.format(&result, None);
282
283        // Colons should be escaped
284        assert!(formatted[0].starts_with("value\\:with\\:colons:"));
285        // All values should be properly formatted
286        assert_eq!(formatted.len(), 3);
287        for line in &formatted {
288            assert!(line.contains(" - "));
289        }
290    }
291
292    #[test]
293    fn test_fish_format_empty_description() {
294        let result = CompletionResult::new()
295            .add("no-desc-value")
296            .add_with_description("with-desc", "Description");
297
298        let formatted = CompletionFormat::Fish.format(&result, None);
299
300        // Fish can have values without descriptions
301        assert_eq!(formatted[0], "no-desc-value");
302        assert!(formatted[1].contains('\t'));
303    }
304
305    #[test]
306    fn test_bash_format() {
307        let result = CompletionResult::new()
308            .add("value1")
309            .add_with_description("value2", "Description ignored for bash");
310
311        let formatted = CompletionFormat::Bash.format(&result, None);
312
313        // Bash format is just the values
314        assert_eq!(formatted, vec!["value1", "value2"]);
315    }
316
317    #[test]
318    fn test_line_length_limits() {
319        let long_value = "a".repeat(50);
320        let long_desc = "b".repeat(50);
321
322        let result = CompletionResult::new().add_with_description(&long_value, &long_desc);
323
324        let formatted = CompletionFormat::Zsh.format(&result, None);
325
326        // All lines should be <= 80 characters
327        for line in formatted {
328            assert!(line.len() <= 80, "Line too long: {} chars", line.len());
329            if line.len() == 80 {
330                assert!(
331                    line.ends_with("..."),
332                    "Long lines should be truncated with ..."
333                );
334            }
335        }
336    }
337
338    #[test]
339    fn test_active_help_formatting() {
340        let result = CompletionResult::new()
341            .add("option1")
342            .add_help_text("This is a help message")
343            .add_conditional_help("Conditional help", |_| true)
344            .add_conditional_help("Hidden help", |_| false);
345
346        let ctx = Context::new(vec![]);
347
348        // Test Bash format
349        let bash_formatted = CompletionFormat::Bash.format(&result, Some(&ctx));
350        assert!(bash_formatted.contains(&"option1".to_string()));
351        assert!(bash_formatted.contains(&"_activehelp_ This is a help message".to_string()));
352        assert!(bash_formatted.contains(&"_activehelp_ Conditional help".to_string()));
353        assert!(!bash_formatted.iter().any(|s| s.contains("Hidden help")));
354
355        // Test Zsh format
356        let zsh_formatted = CompletionFormat::Zsh.format(&result, Some(&ctx));
357        assert!(
358            zsh_formatted
359                .iter()
360                .any(|s| s.contains("_activehelp_::This is a help message"))
361        );
362        assert!(
363            zsh_formatted
364                .iter()
365                .any(|s| s.contains("_activehelp_::Conditional help"))
366        );
367
368        // Test Fish format
369        let fish_formatted = CompletionFormat::Fish.format(&result, Some(&ctx));
370        assert!(fish_formatted.contains(&"option1".to_string()));
371        assert!(fish_formatted.contains(&"_activehelp_\tThis is a help message".to_string()));
372        assert!(fish_formatted.contains(&"_activehelp_\tConditional help".to_string()));
373
374        // Test without context - no ActiveHelp should be shown
375        let no_ctx_formatted = CompletionFormat::Bash.format(&result, None);
376        assert!(!no_ctx_formatted.iter().any(|s| s.contains("_activehelp_")));
377    }
378}