@@ -115,6 +115,8 @@ pub fn escape_word_array_content(
115115 output. push ( next) ;
116116 }
117117 bytes. next ( ) ;
118+ } else {
119+ output. push ( b'\\' ) ;
118120 }
119121 } else if c == TARGET_OPEN || c == TARGET_CLOSE {
120122 // Unescaped target delimiter needs escaping
@@ -212,3 +214,229 @@ fn escape_string(content: &[u8], opening_delim: u8, closing_delim: u8) -> Vec<u8
212214
213215 output
214216}
217+
218+ #[ cfg( test) ]
219+ mod tests {
220+ use super :: * ;
221+
222+ // --- escape_string with single-quoted strings ---
223+
224+ #[ test]
225+ fn single_quoted_empty ( ) {
226+ assert_eq ! ( single_to_double_quoted( b"" , b"'" , b"'" ) . as_ref( ) , b"" ) ;
227+ }
228+
229+ #[ test]
230+ fn single_quoted_plain_content ( ) {
231+ assert_eq ! (
232+ single_to_double_quoted( b"hello" , b"'" , b"'" ) . as_ref( ) ,
233+ b"hello"
234+ ) ;
235+ }
236+
237+ #[ test]
238+ fn single_quoted_escapes_double_quote ( ) {
239+ assert_eq ! (
240+ single_to_double_quoted( b"say \" hi\" " , b"'" , b"'" ) . as_ref( ) ,
241+ b"say \\ \" hi\\ \" "
242+ ) ;
243+ }
244+
245+ #[ test]
246+ fn single_quoted_strips_backslash_before_single_quote ( ) {
247+ // \' is valid in single-quoted strings but meaningless in double-quoted; strip it
248+ assert_eq ! ( single_to_double_quoted( b"\\ '" , b"'" , b"'" ) . as_ref( ) , b"'" ) ;
249+ }
250+
251+ #[ test]
252+ fn single_quoted_preserves_escaped_backslash ( ) {
253+ assert_eq ! (
254+ single_to_double_quoted( b"\\ \\ " , b"'" , b"'" ) . as_ref( ) ,
255+ b"\\ \\ "
256+ ) ;
257+ }
258+
259+ #[ test]
260+ fn single_quoted_backslash_before_double_quote ( ) {
261+ // '\"' is a literal backslash + double-quote (not an escape sequence in single-quoted).
262+ // In double-quoted, that must be \\\" (escaped backslash, then escaped quote).
263+ assert_eq ! (
264+ single_to_double_quoted( b"\\ \" " , b"'" , b"'" ) . as_ref( ) ,
265+ b"\\ \\ \\ \" "
266+ ) ;
267+ }
268+
269+ #[ test]
270+ fn single_quoted_doubles_backslash_before_unknown_escape ( ) {
271+ // '\n' in single-quoted is a literal backslash + n (not a newline).
272+ // In double-quoted, \n would be a newline, so the backslash must be doubled.
273+ assert_eq ! (
274+ single_to_double_quoted( b"\\ n" , b"'" , b"'" ) . as_ref( ) ,
275+ b"\\ \\ n"
276+ ) ;
277+ }
278+
279+ #[ test]
280+ fn single_quoted_escapes_hash_dollar_interpolation ( ) {
281+ // '#$foo' in double-quoted would interpolate $foo — escape the #
282+ assert_eq ! (
283+ single_to_double_quoted( b"#$foo" , b"'" , b"'" ) . as_ref( ) ,
284+ b"\\ #$foo"
285+ ) ;
286+ }
287+
288+ #[ test]
289+ fn single_quoted_escapes_hash_at_interpolation ( ) {
290+ assert_eq ! (
291+ single_to_double_quoted( b"#@foo" , b"'" , b"'" ) . as_ref( ) ,
292+ b"\\ #@foo"
293+ ) ;
294+ }
295+
296+ #[ test]
297+ fn single_quoted_escapes_hash_brace_interpolation ( ) {
298+ assert_eq ! (
299+ single_to_double_quoted( b"#{foo}" , b"'" , b"'" ) . as_ref( ) ,
300+ b"\\ #{foo}"
301+ ) ;
302+ }
303+
304+ #[ test]
305+ fn single_quoted_hash_before_regular_char_not_escaped ( ) {
306+ assert_eq ! (
307+ single_to_double_quoted( b"#foo" , b"'" , b"'" ) . as_ref( ) ,
308+ b"#foo"
309+ ) ;
310+ }
311+
312+ // --- escape_string with %q(...) strings ---
313+
314+ #[ test]
315+ fn percent_q_unescapes_open_delimiter ( ) {
316+ // \( was escaping the %q( delimiter; in double-quoted it's just (
317+ assert_eq ! ( single_to_double_quoted( b"\\ (" , b"%q(" , b")" ) . as_ref( ) , b"(" ) ;
318+ }
319+
320+ #[ test]
321+ fn percent_q_unescapes_close_delimiter ( ) {
322+ assert_eq ! ( single_to_double_quoted( b"\\ )" , b"%q(" , b")" ) . as_ref( ) , b")" ) ;
323+ }
324+
325+ #[ test]
326+ fn percent_q_escapes_double_quote ( ) {
327+ assert_eq ! (
328+ single_to_double_quoted( b"\" " , b"%q(" , b")" ) . as_ref( ) ,
329+ b"\\ \" "
330+ ) ;
331+ }
332+
333+ // --- convert_percent_literal_escapes with %Q(...) strings ---
334+
335+ #[ test]
336+ fn percent_q_upper_no_changes_returns_borrowed ( ) {
337+ // No " and no escaped delimiters: should return the original slice
338+ let result = single_to_double_quoted ( b"hello world" , b"%Q(" , b")" ) ;
339+ assert ! ( matches!( result, Cow :: Borrowed ( _) ) ) ;
340+ assert_eq ! ( result. as_ref( ) , b"hello world" ) ;
341+ }
342+
343+ #[ test]
344+ fn percent_q_upper_double_quote_triggers_owned ( ) {
345+ let result = single_to_double_quoted ( b"say \" hi\" " , b"%Q(" , b")" ) ;
346+ assert ! ( matches!( result, Cow :: Owned ( _) ) ) ;
347+ assert_eq ! ( result. as_ref( ) , b"say \\ \" hi\\ \" " ) ;
348+ }
349+
350+ #[ test]
351+ fn percent_q_upper_unescapes_open_delimiter ( ) {
352+ assert_eq ! ( single_to_double_quoted( b"\\ (" , b"%Q(" , b")" ) . as_ref( ) , b"(" ) ;
353+ }
354+
355+ #[ test]
356+ fn percent_q_upper_unescapes_close_delimiter ( ) {
357+ assert_eq ! ( single_to_double_quoted( b"\\ )" , b"%Q(" , b")" ) . as_ref( ) , b")" ) ;
358+ }
359+
360+ #[ test]
361+ fn percent_q_upper_preserves_other_backslash_sequences ( ) {
362+ let result = single_to_double_quoted ( b"\\ n" , b"%Q(" , b")" ) ;
363+ assert ! ( matches!( result, Cow :: Borrowed ( _) ) ) ;
364+ assert_eq ! ( result. as_ref( ) , b"\\ n" ) ;
365+ }
366+
367+ #[ test]
368+ fn percent_q_upper_preserves_escaped_backslash ( ) {
369+ let result = single_to_double_quoted ( b"\\ \\ " , b"%Q(" , b")" ) ;
370+ assert ! ( matches!( result, Cow :: Borrowed ( _) ) ) ;
371+ assert_eq ! ( result. as_ref( ) , b"\\ \\ " ) ;
372+ }
373+
374+ #[ test]
375+ fn percent_q_upper_trailing_backslash_preserved ( ) {
376+ let result = single_to_double_quoted ( b"foo\\ " , b"%Q(" , b")" ) ;
377+ assert ! ( matches!( result, Cow :: Borrowed ( _) ) ) ;
378+ assert_eq ! ( result. as_ref( ) , b"foo\\ " ) ;
379+ }
380+
381+ // --- escape_word_array_content ---
382+
383+ #[ test]
384+ fn word_array_plain_content ( ) {
385+ assert_eq ! ( escape_word_array_content( b"hello" , b'(' , b')' ) , b"hello" ) ;
386+ }
387+
388+ #[ test]
389+ fn word_array_escapes_open_bracket ( ) {
390+ assert_eq ! ( escape_word_array_content( b"[" , b'(' , b')' ) , b"\\ [" ) ;
391+ }
392+
393+ #[ test]
394+ fn word_array_escapes_close_bracket ( ) {
395+ assert_eq ! ( escape_word_array_content( b"]" , b'(' , b')' ) , b"\\ ]" ) ;
396+ }
397+
398+ #[ test]
399+ fn word_array_unescapes_original_open_delim ( ) {
400+ // \( was needed to escape the %w( delimiter; in %w[...] it's just (
401+ assert_eq ! ( escape_word_array_content( b"\\ (" , b'(' , b')' ) , b"(" ) ;
402+ }
403+
404+ #[ test]
405+ fn word_array_unescapes_original_close_delim ( ) {
406+ assert_eq ! ( escape_word_array_content( b"\\ )" , b'(' , b')' ) , b")" ) ;
407+ }
408+
409+ #[ test]
410+ fn word_array_preserves_escaped_backslash ( ) {
411+ assert_eq ! ( escape_word_array_content( b"\\ \\ " , b'(' , b')' ) , b"\\ \\ " ) ;
412+ }
413+
414+ #[ test]
415+ fn word_array_keeps_already_escaped_open_bracket ( ) {
416+ assert_eq ! ( escape_word_array_content( b"\\ [" , b'(' , b')' ) , b"\\ [" ) ;
417+ }
418+
419+ #[ test]
420+ fn word_array_keeps_already_escaped_close_bracket ( ) {
421+ assert_eq ! ( escape_word_array_content( b"\\ ]" , b'(' , b')' ) , b"\\ ]" ) ;
422+ }
423+
424+ #[ test]
425+ fn word_array_preserves_other_backslash_sequences ( ) {
426+ assert_eq ! ( escape_word_array_content( b"\\ n" , b'(' , b')' ) , b"\\ n" ) ;
427+ }
428+
429+ #[ test]
430+ fn word_array_orig_delim_is_target_stays_escaped ( ) {
431+ // When orig delim is [ (same as the target), \[ must remain \[ rather than being unescaped
432+ assert_eq ! ( escape_word_array_content( b"\\ [" , b'[' , b']' ) , b"\\ [" ) ;
433+ }
434+
435+ #[ test]
436+ fn word_array_trailing_backslash_preserved ( ) {
437+ // Technically this probably isn't reachable in valid Ruby code,
438+ // e.g. `%w(foo\)` is not valid, but we have it here defensively to
439+ // prevent accidentally dropping a slash in an edge case
440+ assert_eq ! ( escape_word_array_content( b"foo\\ " , b'(' , b')' ) , b"foo\\ " ) ;
441+ }
442+ }
0 commit comments