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)]
30pub fn expand_backslash_escapes(
31 s: &str,
32 mode: EscapeExpansionMode,
33) -> Result<(Vec<u8>, bool), error::Error> {
34 let mut result: Vec<u8> = Vec::with_capacity(s.len());
35 let mut it = s.chars();
36 while let Some(c) = it.next() {
37 if c != '\\' {
38 result.append(c.to_string().into_bytes().as_mut());
40 continue;
41 }
42
43 let Some(escape_cmd) = it.next() else {
44 result.push(b'\\');
46 continue;
47 };
48
49 match escape_cmd {
50 'a' => result.push(b'\x07'),
51 'b' => result.push(b'\x08'),
52 'c' => {
53 match mode {
54 EscapeExpansionMode::EchoBuiltin => {
55 return Ok((result, false));
57 }
58 EscapeExpansionMode::AnsiCQuotes => {
59 if let Some(char_value) = it.next() {
60 if char_value == '\\' {
68 let orig_it = it.clone();
69 if !matches!(it.next(), Some('\\')) {
70 it = orig_it;
73 }
74 }
75
76 let mut bytes: Vec<u8> = if char_value.is_ascii_lowercase() {
77 char_value
78 .to_ascii_uppercase()
79 .to_string()
80 .bytes()
81 .collect()
82 } else {
83 char_value.to_string().bytes().collect()
84 };
85
86 if !bytes.is_empty() {
87 if bytes[0] == b'?' {
88 bytes[0] = 0x7f;
91 } else {
92 bytes[0] &= 0x1f;
93 }
94 }
95
96 result.append(bytes.as_mut());
97 } else {
98 result.push(b'\\');
99 result.push(b'c');
100 }
101 }
102 }
103 }
104 'e' | 'E' => result.push(b'\x1b'),
105 'f' => result.push(b'\x0c'),
106 'n' => result.push(b'\n'),
107 'r' => result.push(b'\r'),
108 't' => result.push(b'\t'),
109 'v' => result.push(b'\x0b'),
110 '\\' => result.push(b'\\'),
111 '\'' if matches!(mode, EscapeExpansionMode::AnsiCQuotes) => result.push(b'\''),
112 '\"' if matches!(mode, EscapeExpansionMode::AnsiCQuotes) => result.push(b'\"'),
113 '?' if matches!(mode, EscapeExpansionMode::AnsiCQuotes) => result.push(b'?'),
114 '0' => {
115 let mut taken_so_far = 0;
117 let mut octal_chars: String = it
118 .take_while_ref(|c| {
119 if taken_so_far < 3 && matches!(*c, '0'..='7') {
120 taken_so_far += 1;
121 true
122 } else {
123 false
124 }
125 })
126 .collect();
127
128 if octal_chars.is_empty() {
129 octal_chars.push('0');
130 }
131
132 let value = int_utils::parse::<u8>(octal_chars.as_str(), 8)?;
133 result.push(value);
134 }
135 'x' => {
136 let mut hex_chars = String::new();
139 let mut invalid_prefix = false;
140 let mut hexits_consumed = 0;
141 let mut start_brace_consumed = false;
142
143 loop {
144 let orig_it = it.clone();
147
148 let Some(next_c) = it.next() else {
149 break;
150 };
151
152 if matches!(mode, EscapeExpansionMode::AnsiCQuotes)
153 && !start_brace_consumed
154 && next_c == '{'
155 {
156 start_brace_consumed = true;
157 } else if start_brace_consumed && next_c == '}' {
158 break;
159 } else if ((start_brace_consumed && !invalid_prefix)
160 || (!start_brace_consumed && hexits_consumed < 2))
161 && next_c.is_ascii_hexdigit()
162 {
163 hex_chars.push(next_c);
164 hexits_consumed += 1;
165 } else if start_brace_consumed && hexits_consumed == 0 {
166 invalid_prefix = true;
167 } else {
168 it = orig_it;
170 break;
171 }
172 }
173
174 if hex_chars.is_empty() {
175 if start_brace_consumed {
176 result.push(0);
177 } else {
178 result.push(b'\\');
179 result.append(escape_cmd.to_string().into_bytes().as_mut());
180 }
181 } else {
182 let value32 = int_utils::parse::<u32>(hex_chars.as_str(), 16)?;
183 let value8: u8 = (value32 & 0xff) as u8;
184 result.push(value8);
185 }
186 }
187 'u' => {
188 let mut taken_so_far = 0;
190 let hex_chars: String = it
191 .take_while_ref(|next_c| {
192 if taken_so_far < 4 && next_c.is_ascii_hexdigit() {
193 taken_so_far += 1;
194 true
195 } else {
196 false
197 }
198 })
199 .collect();
200
201 if hex_chars.is_empty() {
202 result.push(b'\\');
203 result.append(escape_cmd.to_string().into_bytes().as_mut());
204 } else {
205 let value = int_utils::parse::<u16>(hex_chars.as_str(), 16)?;
206 if let Some(decoded) = char::from_u32(u32::from(value)) {
207 result.append(decoded.to_string().into_bytes().as_mut());
208 } else {
209 result.push(b'\\');
210 result.append(escape_cmd.to_string().into_bytes().as_mut());
211 }
212 }
213 }
214 'U' => {
215 let mut taken_so_far = 0;
217 let hex_chars: String = it
218 .take_while_ref(|next_c| {
219 if taken_so_far < 8 && next_c.is_ascii_hexdigit() {
220 taken_so_far += 1;
221 true
222 } else {
223 false
224 }
225 })
226 .collect();
227
228 if hex_chars.is_empty() {
229 result.push(b'\\');
230 result.append(escape_cmd.to_string().into_bytes().as_mut());
231 } else {
232 let value = int_utils::parse::<u32>(hex_chars.as_str(), 16)?;
233 if let Some(decoded) = char::from_u32(value) {
234 result.append(decoded.to_string().into_bytes().as_mut());
235 } else {
236 result.push(b'\\');
237 result.append(escape_cmd.to_string().into_bytes().as_mut());
238 }
239 }
240 }
241 first_octal @ '1'..='7' if matches!(mode, EscapeExpansionMode::AnsiCQuotes) => {
242 let mut octal_chars = String::new();
244 octal_chars.push(first_octal);
245
246 let mut taken_so_far = 1;
248 for next_c in it.take_while_ref(|next_c| {
249 if taken_so_far < 3 && matches!(next_c, '0'..='7') {
250 taken_so_far += 1;
251 true
252 } else {
253 false
254 }
255 }) {
256 octal_chars.push(next_c);
257 }
258
259 let value = int_utils::parse::<u8>(octal_chars.as_str(), 8)?;
260 result.push(value);
261 }
262 unknown => {
263 result.push(b'\\');
265 result.append(unknown.to_string().into_bytes().as_mut());
266 }
267 }
268 }
269
270 if matches!(mode, EscapeExpansionMode::AnsiCQuotes) {
272 if let Some(nul_index) = result.iter().position(|&b| b == 0) {
273 result.truncate(nul_index);
274 }
275 }
276
277 Ok((result, true))
278}
279
280#[derive(Clone, Copy, Debug, Default)]
282pub enum QuoteMode {
283 #[default]
285 SingleQuote,
286 DoubleQuote,
288 BackslashEscape,
290}
291
292#[derive(Clone, Debug, Default, bon::Builder)]
300pub struct QuoteOptions {
301 #[builder(default)]
303 pub(crate) always_quote: bool,
304 #[builder(default)]
307 pub(crate) preferred_mode: QuoteMode,
308 #[builder(default)]
311 pub(crate) avoid_ansi_c_quoting_newline: bool,
312 #[builder(default)]
316 pub(crate) leave_leading_hash: bool,
317 #[builder(default)]
321 pub(crate) leave_tilde: bool,
322 #[builder(default)]
326 pub(crate) quote_commas: bool,
327}
328
329pub fn quote<'a>(s: &'a str, options: &QuoteOptions) -> Cow<'a, str> {
337 let use_ansi_c_quotes = s.contains(|c| {
338 needs_ansi_c_quoting(c) && (!options.avoid_ansi_c_quoting_newline || c != '\n')
339 });
340
341 if use_ansi_c_quotes {
342 return ansi_c_quote(s).into();
343 }
344
345 let use_default_quotes =
346 !use_ansi_c_quotes && (options.always_quote || s.is_empty() || needs_quoting(s, options));
347
348 if !use_default_quotes {
349 return s.into();
350 }
351
352 match options.preferred_mode {
353 QuoteMode::BackslashEscape => backslash_escape(s, options),
354 QuoteMode::SingleQuote => single_quote(s),
355 QuoteMode::DoubleQuote => double_quote(s).into(),
356 }
357}
358
359pub fn force_quote(s: &str, mode: QuoteMode) -> String {
366 let options = QuoteOptions {
367 always_quote: true,
368 preferred_mode: mode,
369 ..Default::default()
370 };
371
372 quote(s, &options).to_string()
373}
374
375pub fn quote_if_needed(s: &str, mode: QuoteMode) -> Cow<'_, str> {
383 let options = QuoteOptions {
384 always_quote: false,
385 preferred_mode: mode,
386 ..Default::default()
387 };
388
389 quote(s, &options)
390}
391
392fn backslash_escape<'a>(s: &'a str, options: &QuoteOptions) -> Cow<'a, str> {
393 if s.is_empty() {
394 Cow::Owned("''".to_string())
396 } else if !needs_quoting(s, options) {
397 Cow::Borrowed(s)
398 } else {
399 let mut output = String::with_capacity(s.len());
400 for (i, c) in s.char_indices() {
401 if needs_escaping_at(s, i, c, options) {
402 output.push('\\');
403 }
404 output.push(c);
405 }
406 Cow::Owned(output)
407 }
408}
409
410pub fn single_quote(s: &str) -> Cow<'_, str> {
420 match s {
423 "" => return Cow::Borrowed("''"),
424 "'" => return Cow::Borrowed(r"\'"),
425 _ => (),
426 }
427
428 let mut result = String::with_capacity(s.len() + 2);
429
430 for (i, part) in s.split('\'').enumerate() {
435 if i > 0 {
436 result.push_str(r"\'");
437 }
438
439 result.push('\'');
440 result.push_str(part);
441 result.push('\'');
442 }
443
444 Cow::Owned(result)
445}
446
447fn double_quote(s: &str) -> String {
448 let mut result = String::with_capacity(s.len());
449
450 result.push('"');
451
452 for c in s.chars() {
453 if matches!(c, '$' | '`' | '"' | '\\') {
454 result.push('\\');
455 }
456
457 result.push(c);
458 }
459
460 result.push('"');
461
462 result
463}
464
465fn ansi_c_quote(s: &str) -> String {
466 let mut result = String::with_capacity(s.len());
467 result.push_str("$'");
468
469 for c in s.chars() {
470 match c {
471 '\x07' => result.push_str("\\a"),
472 '\x08' => result.push_str("\\b"),
473 '\x1b' => result.push_str("\\E"),
474 '\x0c' => result.push_str("\\f"),
475 '\n' => result.push_str("\\n"),
476 '\r' => result.push_str("\\r"),
477 '\t' => result.push_str("\\t"),
478 '\x0b' => result.push_str("\\v"),
479 '\\' => result.push_str("\\\\"),
480 '\'' => result.push_str("\\'"),
481 c if needs_ansi_c_quoting(c) => {
482 result.push_str(std::format!("\\{:03o}", c as u8).as_str());
483 }
484 _ => result.push(c),
485 }
486 }
487
488 result.push('\'');
489
490 result
491}
492
493fn needs_quoting(s: &str, options: &QuoteOptions) -> bool {
496 s.char_indices()
497 .any(|(i, c)| needs_escaping_at(s, i, c, options))
498}
499
500fn needs_escaping_at(s: &str, i: usize, c: char, options: &QuoteOptions) -> bool {
505 match c {
506 '#' => i == 0 && !options.leave_leading_hash,
507 '~' if options.leave_tilde => false,
508 ',' => options.quote_commas,
509 '~' => {
510 let prev = s.get(..i).and_then(|before| before.chars().next_back());
511 matches!(prev, None | Some('=' | ':'))
512 }
513 _ => needs_escaping(c),
514 }
515}
516
517const fn needs_escaping(c: char) -> bool {
520 matches!(
521 c,
522 '(' | ')'
523 | '['
524 | ']'
525 | '{'
526 | '}'
527 | '$'
528 | '*'
529 | '?'
530 | '|'
531 | '&'
532 | ';'
533 | '<'
534 | '>'
535 | '`'
536 | '\\'
537 | '"'
538 | '!'
539 | '^'
540 | ' '
541 | '\''
542 )
543}
544
545const fn needs_ansi_c_quoting(c: char) -> bool {
546 c.is_ascii_control()
547}
548
549#[cfg(test)]
550mod tests {
551 use super::*;
552
553 #[test]
554 fn test_backslash_escape() {
555 assert_eq!(quote_if_needed("a", QuoteMode::BackslashEscape), "a");
556 assert_eq!(quote_if_needed("a b", QuoteMode::BackslashEscape), r"a\ b");
557 assert_eq!(quote_if_needed("", QuoteMode::BackslashEscape), "''");
558 }
559
560 #[test]
561 fn test_leading_hash_and_tilde_need_quoting() {
562 assert_eq!(quote_if_needed("#a", QuoteMode::BackslashEscape), r"\#a");
565 assert_eq!(quote_if_needed("~/x", QuoteMode::BackslashEscape), r"\~/x");
566 assert_eq!(quote_if_needed("a=~", QuoteMode::BackslashEscape), r"a=\~");
567 assert_eq!(quote_if_needed("a:~", QuoteMode::BackslashEscape), r"a:\~");
568 assert_eq!(quote_if_needed("#a", QuoteMode::SingleQuote), "'#a'");
569 assert_eq!(quote_if_needed("~", QuoteMode::SingleQuote), "'~'");
570 assert_eq!(quote_if_needed("a#", QuoteMode::SingleQuote), "a#");
572 assert_eq!(quote_if_needed("a~", QuoteMode::SingleQuote), "a~");
573 assert_eq!(quote_if_needed("é=~", QuoteMode::BackslashEscape), r"é=\~");
575 assert_eq!(quote_if_needed("é~", QuoteMode::BackslashEscape), "é~");
576 assert_eq!(quote_if_needed("é#", QuoteMode::BackslashEscape), "é#");
577 }
578
579 #[test]
580 fn test_single_quote_escape() {
581 assert_eq!(quote_if_needed("a", QuoteMode::SingleQuote), "a");
582 assert_eq!(quote_if_needed("a b", QuoteMode::SingleQuote), "'a b'");
583 assert_eq!(quote_if_needed("", QuoteMode::SingleQuote), "''");
584 assert_eq!(quote_if_needed("'", QuoteMode::SingleQuote), r"\'");
585
586 assert_eq!(quote_if_needed("a'b", QuoteMode::SingleQuote), r"'a'\''b'");
590 assert_eq!(
591 quote_if_needed("a''b", QuoteMode::SingleQuote),
592 r"'a'\'''\''b'"
593 );
594 assert_eq!(quote_if_needed("'a", QuoteMode::SingleQuote), r"''\''a'");
595 assert_eq!(quote_if_needed("a'", QuoteMode::SingleQuote), r"'a'\'''");
596 assert_eq!(quote_if_needed("''", QuoteMode::SingleQuote), r"''\'''\'''");
597 }
598
599 #[test]
600 fn test_single_quote_never_upgrades() {
601 assert_eq!(single_quote("a\tb"), "'a\tb'");
604 assert_eq!(single_quote("a\nb"), "'a\nb'");
605 assert_eq!(single_quote("a"), "'a'");
606
607 assert_eq!(quote_if_needed("a\tb", QuoteMode::SingleQuote), r"$'a\tb'");
609 }
610
611 fn assert_echo_expands_to(unexpanded: &str, expected: &str) {
612 assert_eq!(
613 String::from_utf8(
614 expand_backslash_escapes(unexpanded, EscapeExpansionMode::EchoBuiltin)
615 .unwrap()
616 .0
617 )
618 .unwrap(),
619 expected
620 );
621 }
622
623 #[test]
624 fn test_echo_expansion() {
625 assert_echo_expands_to("a", "a");
626 assert_echo_expands_to(r"\M", "\\M");
627 assert_echo_expands_to(r"a\nb", "a\nb");
628 assert_echo_expands_to(r"\a", "\x07");
629 assert_echo_expands_to(r"\b", "\x08");
630 assert_echo_expands_to(r"\e", "\x1b");
631 assert_echo_expands_to(r"\f", "\x0c");
632 assert_echo_expands_to(r"\n", "\n");
633 assert_echo_expands_to(r"\r", "\r");
634 assert_echo_expands_to(r"\t", "\t");
635 assert_echo_expands_to(r"\v", "\x0b");
636 assert_echo_expands_to(r"\\", "\\");
637 assert_echo_expands_to(r"\'", "\\'");
638 assert_echo_expands_to(r#"\""#, r#"\""#);
639 assert_echo_expands_to(r"\?", "\\?");
640 assert_echo_expands_to(r"\0", "\0");
641 assert_echo_expands_to(r"\00", "\0");
642 assert_echo_expands_to(r"\000", "\0");
643 assert_echo_expands_to(r"\081", "\081");
644 assert_echo_expands_to(r"\0101", "A");
645 assert_echo_expands_to(r"abc\", "abc\\");
646 assert_echo_expands_to(r"\x41", "A");
647 assert_echo_expands_to(r"\xf0\x9f\x90\x8d", "🐍");
648 assert_echo_expands_to(r"\u2620", "☠");
649 assert_echo_expands_to(r"\U0001f602", "😂");
650 }
651}