flag_rs/
completion_format.rs1use crate::active_help::ActiveHelp;
7use crate::completion::CompletionResult;
8use crate::context::Context;
9
10#[derive(Debug, Clone, Copy)]
12pub enum CompletionFormat {
13 Simple,
15 Display,
17 Zsh,
19 Fish,
21 Bash,
23}
24
25impl CompletionFormat {
26 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 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 Self::format_zsh(result)
45 }
46 Self::Fish => {
47 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 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 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 fn format_zsh(result: &CompletionResult) -> Vec<String> {
94 const MAX_WIDTH: usize = 80;
96
97 let max_value_width = result.values.iter().map(String::len).max().unwrap_or(0);
99 let padding = max_value_width.min(35) + 4;
102
103 result
104 .values
105 .iter()
106 .zip(&result.descriptions)
107 .map(|(value, desc)| {
108 let escaped_value = value.replace(':', "\\:");
110
111 if desc.is_empty() {
112 format!("{escaped_value}:{escaped_value} - ")
114 } else {
115 let formatted_desc = if value.len() <= 35 {
118 format!("{escaped_value:<padding$}- {desc}")
119 } else {
120 format!("{escaped_value} - {desc}")
122 };
123
124 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 fn format_fish(result: &CompletionResult) -> Vec<String> {
138 const MAX_WIDTH: usize = 80;
140
141 let max_value_width = result.values.iter().map(String::len).max().unwrap_or(0);
143 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 value.clone()
154 } else {
155 let formatted_desc = if value.len() <= 35 {
158 format!("{value:<padding$}- {desc}")
159 } else {
160 format!("{value} - {desc}")
162 };
163
164 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 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 formatted.push(format!("_activehelp_ {}", help.message));
193 }
194 Self::Zsh => {
195 formatted.push(format!("_activehelp_::{}", help.message));
198 }
199 Self::Fish => {
200 formatted.push(format!("_activehelp_\t{}", help.message));
202 }
203 Self::Simple | Self::Display => {
204 formatted.push(format!("[HELP] {}", help.message));
206 }
207 }
208 }
209 }
210
211 formatted
212 }
213}
214
215fn 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 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 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 assert!(formatted[0].contains(':'));
260 assert!(formatted[0].contains(" - "));
261 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 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 assert!(formatted[0].starts_with("value\\:with\\:colons:"));
294 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 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 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 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 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 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 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 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 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 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}