1use std::borrow::Cow;
4
5use itertools::Itertools;
6
7use crate::{error, int_utils};
8
9#[derive(Clone, Copy)]
11pub enum EscapeExpansionMode {
12 EchoBuiltin,
14 AnsiCQuotes,
16}
17
18#[expect(clippy::too_many_lines)]
25pub fn expand_backslash_escapes(
26 s: &str,
27 mode: EscapeExpansionMode,
28) -> Result<(Vec<u8>, bool), error::Error> {
29 let mut result: Vec<u8> = Vec::with_capacity(s.len());
30 let mut it = s.chars();
31 while let Some(c) = it.next() {
32 if c != '\\' {
33 result.append(c.to_string().into_bytes().as_mut());
35 continue;
36 }
37
38 let Some(escape_cmd) = it.next() else {
39 result.push(b'\\');
41 continue;
42 };
43
44 match escape_cmd {
45 'a' => result.push(b'\x07'),
46 'b' => result.push(b'\x08'),
47 'c' => {
48 match mode {
49 EscapeExpansionMode::EchoBuiltin => {
50 return Ok((result, false));
52 }
53 EscapeExpansionMode::AnsiCQuotes => {
54 if let Some(char_value) = it.next() {
55 if char_value == '\\' {
63 let orig_it = it.clone();
64 if !matches!(it.next(), Some('\\')) {
65 it = orig_it;
68 }
69 }
70
71 let mut bytes: Vec<u8> = if char_value.is_ascii_lowercase() {
72 char_value
73 .to_ascii_uppercase()
74 .to_string()
75 .bytes()
76 .collect()
77 } else {
78 char_value.to_string().bytes().collect()
79 };
80
81 if !bytes.is_empty() {
82 if bytes[0] == b'?' {
83 bytes[0] = 0x7f;
86 } else {
87 bytes[0] &= 0x1f;
88 }
89 }
90
91 result.append(bytes.as_mut());
92 } else {
93 result.push(b'\\');
94 result.push(b'c');
95 }
96 }
97 }
98 }
99 'e' | 'E' => result.push(b'\x1b'),
100 'f' => result.push(b'\x0c'),
101 'n' => result.push(b'\n'),
102 'r' => result.push(b'\r'),
103 't' => result.push(b'\t'),
104 'v' => result.push(b'\x0b'),
105 '\\' => result.push(b'\\'),
106 '\'' if matches!(mode, EscapeExpansionMode::AnsiCQuotes) => result.push(b'\''),
107 '\"' if matches!(mode, EscapeExpansionMode::AnsiCQuotes) => result.push(b'\"'),
108 '?' if matches!(mode, EscapeExpansionMode::AnsiCQuotes) => result.push(b'?'),
109 '0' => {
110 let mut taken_so_far = 0;
112 let mut octal_chars: String = it
113 .take_while_ref(|c| {
114 if taken_so_far < 3 && matches!(*c, '0'..='7') {
115 taken_so_far += 1;
116 true
117 } else {
118 false
119 }
120 })
121 .collect();
122
123 if octal_chars.is_empty() {
124 octal_chars.push('0');
125 }
126
127 let value = int_utils::parse::<u8>(octal_chars.as_str(), 8)?;
128 result.push(value);
129 }
130 'x' => {
131 let mut hex_chars = String::new();
134 let mut invalid_prefix = false;
135 let mut hexits_consumed = 0;
136 let mut start_brace_consumed = false;
137
138 loop {
139 let orig_it = it.clone();
142
143 let Some(next_c) = it.next() else {
144 break;
145 };
146
147 if matches!(mode, EscapeExpansionMode::AnsiCQuotes)
148 && !start_brace_consumed
149 && next_c == '{'
150 {
151 start_brace_consumed = true;
152 } else if start_brace_consumed && next_c == '}' {
153 break;
154 } else if ((start_brace_consumed && !invalid_prefix)
155 || (!start_brace_consumed && hexits_consumed < 2))
156 && next_c.is_ascii_hexdigit()
157 {
158 hex_chars.push(next_c);
159 hexits_consumed += 1;
160 } else if start_brace_consumed && hexits_consumed == 0 {
161 invalid_prefix = true;
162 } else {
163 it = orig_it;
165 break;
166 }
167 }
168
169 if hex_chars.is_empty() {
170 if start_brace_consumed {
171 result.push(0);
172 } else {
173 result.push(b'\\');
174 result.append(escape_cmd.to_string().into_bytes().as_mut());
175 }
176 } else {
177 let value32 = int_utils::parse::<u32>(hex_chars.as_str(), 16)?;
178 let value8: u8 = (value32 & 0xff) as u8;
179 result.push(value8);
180 }
181 }
182 'u' => {
183 let mut taken_so_far = 0;
185 let hex_chars: String = it
186 .take_while_ref(|next_c| {
187 if taken_so_far < 4 && next_c.is_ascii_hexdigit() {
188 taken_so_far += 1;
189 true
190 } else {
191 false
192 }
193 })
194 .collect();
195
196 if hex_chars.is_empty() {
197 result.push(b'\\');
198 result.append(escape_cmd.to_string().into_bytes().as_mut());
199 } else {
200 let value = int_utils::parse::<u16>(hex_chars.as_str(), 16)?;
201 if let Some(decoded) = char::from_u32(u32::from(value)) {
202 result.append(decoded.to_string().into_bytes().as_mut());
203 } else {
204 result.push(b'\\');
205 result.append(escape_cmd.to_string().into_bytes().as_mut());
206 }
207 }
208 }
209 'U' => {
210 let mut taken_so_far = 0;
212 let hex_chars: String = it
213 .take_while_ref(|next_c| {
214 if taken_so_far < 8 && next_c.is_ascii_hexdigit() {
215 taken_so_far += 1;
216 true
217 } else {
218 false
219 }
220 })
221 .collect();
222
223 if hex_chars.is_empty() {
224 result.push(b'\\');
225 result.append(escape_cmd.to_string().into_bytes().as_mut());
226 } else {
227 let value = int_utils::parse::<u32>(hex_chars.as_str(), 16)?;
228 if let Some(decoded) = char::from_u32(value) {
229 result.append(decoded.to_string().into_bytes().as_mut());
230 } else {
231 result.push(b'\\');
232 result.append(escape_cmd.to_string().into_bytes().as_mut());
233 }
234 }
235 }
236 first_octal @ '1'..='7' if matches!(mode, EscapeExpansionMode::AnsiCQuotes) => {
237 let mut octal_chars = String::new();
239 octal_chars.push(first_octal);
240
241 let mut taken_so_far = 1;
243 for next_c in it.take_while_ref(|next_c| {
244 if taken_so_far < 3 && matches!(next_c, '0'..='7') {
245 taken_so_far += 1;
246 true
247 } else {
248 false
249 }
250 }) {
251 octal_chars.push(next_c);
252 }
253
254 let value = int_utils::parse::<u8>(octal_chars.as_str(), 8)?;
255 result.push(value);
256 }
257 unknown => {
258 result.push(b'\\');
260 result.append(unknown.to_string().into_bytes().as_mut());
261 }
262 }
263 }
264
265 if matches!(mode, EscapeExpansionMode::AnsiCQuotes) {
267 if let Some(nul_index) = result.iter().position(|&b| b == 0) {
268 result.truncate(nul_index);
269 }
270 }
271
272 Ok((result, true))
273}
274
275#[derive(Clone, Copy, Debug, Default)]
277pub enum QuoteMode {
278 #[default]
280 SingleQuote,
281 DoubleQuote,
283 BackslashEscape,
285}
286
287#[derive(Clone, Debug, Default, bon::Builder)]
295pub struct QuoteOptions {
296 #[builder(default)]
298 pub(crate) always_quote: bool,
299 #[builder(default)]
302 pub(crate) preferred_mode: QuoteMode,
303 #[builder(default)]
306 pub(crate) avoid_ansi_c_quoting_newline: bool,
307 #[builder(default)]
311 pub(crate) leave_leading_hash: bool,
312 #[builder(default)]
316 pub(crate) leave_tilde: bool,
317 #[builder(default)]
321 pub(crate) quote_commas: bool,
322}
323
324pub fn quote<'a>(s: &'a str, options: &QuoteOptions) -> Cow<'a, str> {
332 let use_ansi_c_quotes = s.contains(|c| {
333 needs_ansi_c_quoting(c) && (!options.avoid_ansi_c_quoting_newline || c != '\n')
334 });
335
336 if use_ansi_c_quotes {
337 return ansi_c_quote(s).into();
338 }
339
340 let use_default_quotes =
341 !use_ansi_c_quotes && (options.always_quote || s.is_empty() || needs_quoting(s, options));
342
343 if !use_default_quotes {
344 return s.into();
345 }
346
347 match options.preferred_mode {
348 QuoteMode::BackslashEscape => backslash_escape(s, options),
349 QuoteMode::SingleQuote => single_quote(s),
350 QuoteMode::DoubleQuote => double_quote(s).into(),
351 }
352}
353
354pub fn force_quote(s: &str, mode: QuoteMode) -> String {
361 let options = QuoteOptions {
362 always_quote: true,
363 preferred_mode: mode,
364 ..Default::default()
365 };
366
367 quote(s, &options).to_string()
368}
369
370pub fn quote_if_needed(s: &str, mode: QuoteMode) -> Cow<'_, str> {
378 let options = QuoteOptions {
379 always_quote: false,
380 preferred_mode: mode,
381 ..Default::default()
382 };
383
384 quote(s, &options)
385}
386
387fn backslash_escape<'a>(s: &'a str, options: &QuoteOptions) -> Cow<'a, str> {
388 if s.is_empty() {
389 Cow::Owned("''".to_string())
391 } else if !needs_quoting(s, options) {
392 Cow::Borrowed(s)
393 } else {
394 let mut output = String::with_capacity(s.len());
395 for (i, c) in s.char_indices() {
396 if needs_escaping_at(s, i, c, options) {
397 output.push('\\');
398 }
399 output.push(c);
400 }
401 Cow::Owned(output)
402 }
403}
404
405pub fn single_quote(s: &str) -> Cow<'_, str> {
415 match s {
418 "" => return Cow::Borrowed("''"),
419 "'" => return Cow::Borrowed(r"\'"),
420 _ => (),
421 }
422
423 let mut result = String::with_capacity(s.len() + 2);
424
425 for (i, part) in s.split('\'').enumerate() {
430 if i > 0 {
431 result.push_str(r"\'");
432 }
433
434 result.push('\'');
435 result.push_str(part);
436 result.push('\'');
437 }
438
439 Cow::Owned(result)
440}
441
442fn double_quote(s: &str) -> String {
443 let mut result = String::with_capacity(s.len());
444
445 result.push('"');
446
447 for c in s.chars() {
448 if matches!(c, '$' | '`' | '"' | '\\') {
449 result.push('\\');
450 }
451
452 result.push(c);
453 }
454
455 result.push('"');
456
457 result
458}
459
460fn ansi_c_quote(s: &str) -> String {
461 let mut result = String::with_capacity(s.len());
462 result.push_str("$'");
463
464 for c in s.chars() {
465 match c {
466 '\x07' => result.push_str("\\a"),
467 '\x08' => result.push_str("\\b"),
468 '\x1b' => result.push_str("\\E"),
469 '\x0c' => result.push_str("\\f"),
470 '\n' => result.push_str("\\n"),
471 '\r' => result.push_str("\\r"),
472 '\t' => result.push_str("\\t"),
473 '\x0b' => result.push_str("\\v"),
474 '\\' => result.push_str("\\\\"),
475 '\'' => result.push_str("\\'"),
476 c if needs_ansi_c_quoting(c) => {
477 result.push_str(std::format!("\\{:03o}", c as u8).as_str());
478 }
479 _ => result.push(c),
480 }
481 }
482
483 result.push('\'');
484
485 result
486}
487
488fn needs_quoting(s: &str, options: &QuoteOptions) -> bool {
491 s.char_indices()
492 .any(|(i, c)| needs_escaping_at(s, i, c, options))
493}
494
495fn needs_escaping_at(s: &str, i: usize, c: char, options: &QuoteOptions) -> bool {
500 match c {
501 '#' => i == 0 && !options.leave_leading_hash,
502 '~' if options.leave_tilde => false,
503 ',' => options.quote_commas,
504 '~' => {
505 let prev = s.get(..i).and_then(|before| before.chars().next_back());
506 matches!(prev, None | Some('=' | ':'))
507 }
508 _ => needs_escaping(c),
509 }
510}
511
512const fn needs_escaping(c: char) -> bool {
515 matches!(
516 c,
517 '(' | ')'
518 | '['
519 | ']'
520 | '{'
521 | '}'
522 | '$'
523 | '*'
524 | '?'
525 | '|'
526 | '&'
527 | ';'
528 | '<'
529 | '>'
530 | '`'
531 | '\\'
532 | '"'
533 | '!'
534 | '^'
535 | ' '
536 | '\''
537 )
538}
539
540const fn needs_ansi_c_quoting(c: char) -> bool {
541 c.is_ascii_control()
542}
543
544#[cfg(test)]
545mod tests {
546 use super::*;
547
548 #[test]
549 fn test_backslash_escape() {
550 assert_eq!(quote_if_needed("a", QuoteMode::BackslashEscape), "a");
551 assert_eq!(quote_if_needed("a b", QuoteMode::BackslashEscape), r"a\ b");
552 assert_eq!(quote_if_needed("", QuoteMode::BackslashEscape), "''");
553 }
554
555 #[test]
556 fn test_leading_hash_and_tilde_need_quoting() {
557 assert_eq!(quote_if_needed("#a", QuoteMode::BackslashEscape), r"\#a");
560 assert_eq!(quote_if_needed("~/x", QuoteMode::BackslashEscape), r"\~/x");
561 assert_eq!(quote_if_needed("a=~", QuoteMode::BackslashEscape), r"a=\~");
562 assert_eq!(quote_if_needed("a:~", QuoteMode::BackslashEscape), r"a:\~");
563 assert_eq!(quote_if_needed("#a", QuoteMode::SingleQuote), "'#a'");
564 assert_eq!(quote_if_needed("~", QuoteMode::SingleQuote), "'~'");
565 assert_eq!(quote_if_needed("a#", QuoteMode::SingleQuote), "a#");
567 assert_eq!(quote_if_needed("a~", QuoteMode::SingleQuote), "a~");
568 assert_eq!(quote_if_needed("é=~", QuoteMode::BackslashEscape), r"é=\~");
570 assert_eq!(quote_if_needed("é~", QuoteMode::BackslashEscape), "é~");
571 assert_eq!(quote_if_needed("é#", QuoteMode::BackslashEscape), "é#");
572 }
573
574 #[test]
575 fn test_single_quote_escape() {
576 assert_eq!(quote_if_needed("a", QuoteMode::SingleQuote), "a");
577 assert_eq!(quote_if_needed("a b", QuoteMode::SingleQuote), "'a b'");
578 assert_eq!(quote_if_needed("", QuoteMode::SingleQuote), "''");
579 assert_eq!(quote_if_needed("'", QuoteMode::SingleQuote), r"\'");
580
581 assert_eq!(quote_if_needed("a'b", QuoteMode::SingleQuote), r"'a'\''b'");
585 assert_eq!(
586 quote_if_needed("a''b", QuoteMode::SingleQuote),
587 r"'a'\'''\''b'"
588 );
589 assert_eq!(quote_if_needed("'a", QuoteMode::SingleQuote), r"''\''a'");
590 assert_eq!(quote_if_needed("a'", QuoteMode::SingleQuote), r"'a'\'''");
591 assert_eq!(quote_if_needed("''", QuoteMode::SingleQuote), r"''\'''\'''");
592 }
593
594 #[test]
595 fn test_single_quote_never_upgrades() {
596 assert_eq!(single_quote("a\tb"), "'a\tb'");
599 assert_eq!(single_quote("a\nb"), "'a\nb'");
600 assert_eq!(single_quote("a"), "'a'");
601
602 assert_eq!(quote_if_needed("a\tb", QuoteMode::SingleQuote), r"$'a\tb'");
604 }
605
606 fn assert_echo_expands_to(unexpanded: &str, expected: &str) {
607 assert_eq!(
608 String::from_utf8(
609 expand_backslash_escapes(unexpanded, EscapeExpansionMode::EchoBuiltin)
610 .unwrap()
611 .0
612 )
613 .unwrap(),
614 expected
615 );
616 }
617
618 #[test]
619 fn test_echo_expansion() {
620 assert_echo_expands_to("a", "a");
621 assert_echo_expands_to(r"\M", "\\M");
622 assert_echo_expands_to(r"a\nb", "a\nb");
623 assert_echo_expands_to(r"\a", "\x07");
624 assert_echo_expands_to(r"\b", "\x08");
625 assert_echo_expands_to(r"\e", "\x1b");
626 assert_echo_expands_to(r"\f", "\x0c");
627 assert_echo_expands_to(r"\n", "\n");
628 assert_echo_expands_to(r"\r", "\r");
629 assert_echo_expands_to(r"\t", "\t");
630 assert_echo_expands_to(r"\v", "\x0b");
631 assert_echo_expands_to(r"\\", "\\");
632 assert_echo_expands_to(r"\'", "\\'");
633 assert_echo_expands_to(r#"\""#, r#"\""#);
634 assert_echo_expands_to(r"\?", "\\?");
635 assert_echo_expands_to(r"\0", "\0");
636 assert_echo_expands_to(r"\00", "\0");
637 assert_echo_expands_to(r"\000", "\0");
638 assert_echo_expands_to(r"\081", "\081");
639 assert_echo_expands_to(r"\0101", "A");
640 assert_echo_expands_to(r"abc\", "abc\\");
641 assert_echo_expands_to(r"\x41", "A");
642 assert_echo_expands_to(r"\xf0\x9f\x90\x8d", "🐍");
643 assert_echo_expands_to(r"\u2620", "☠");
644 assert_echo_expands_to(r"\U0001f602", "😂");
645 }
646}