Repository: ruby/yarp (c)

Issue:
Different 'unescaped' result for %w[] vs regular string
```ruby
irb(main):001:0> YARP.parse('"foo\ bar"').value.statements.body.first.unescaped
=> "foo bar"
irb(main):002:0> YARP.parse('%w[foo\ bar]').value.statements.body.first.elements.first.unescaped
=> "foo\\ bar"
```

Pull request description:
fix: handling escaped whitespace in a %w list
Introduces a new flavor of unescaping, YP_UNESCAPE_WHITESPACE, which is the same as MINIMAL but also unescapes whitespace.

Note that a spanning_heredoc.txt fixture test is updated to be less wrong, but YARP's behavior doesn't yet fully match Ruby in this case.

Fixes #1505

Patch:
diff --git a/ext/yarp/extension.c b/ext/yarp/extension.c
index f951b0c4..b5fe43b3 100644
--- a/ext/yarp/extension.c
+++ b/ext/yarp/extension.c
@@ -491,6 +491,12 @@ unescape_minimal(VALUE self, VALUE source) {
     return unescape(source, YP_UNESCAPE_MINIMAL);
 }
 
+// Escape the given string minimally plus whitespace. Returns the unescaped string.
+static VALUE
+unescape_whitespace(VALUE self, VALUE source) {
+    return unescape(source, YP_UNESCAPE_WHITESPACE);
+}
+
 // Unescape everything in the given string. Return the unescaped string.
 static VALUE
 unescape_all(VALUE self, VALUE source) {
@@ -608,6 +614,7 @@ Init_yarp(void) {
     rb_define_singleton_method(rb_cYARPDebug, "named_captures", named_captures, 1);
     rb_define_singleton_method(rb_cYARPDebug, "unescape_none", unescape_none, 1);
     rb_define_singleton_method(rb_cYARPDebug, "unescape_minimal", unescape_minimal, 1);
+    rb_define_singleton_method(rb_cYARPDebug, "unescape_whitespace", unescape_whitespace, 1);
     rb_define_singleton_method(rb_cYARPDebug, "unescape_all", unescape_all, 1);
     rb_define_singleton_method(rb_cYARPDebug, "memsize", memsize, 1);
     rb_define_singleton_method(rb_cYARPDebug, "profile_file", profile_file, 1);
diff --git a/include/yarp/unescape.h b/include/yarp/unescape.h
index fb0df9fc..bb0ef315 100644
--- a/include/yarp/unescape.h
+++ b/include/yarp/unescape.h
@@ -24,9 +24,13 @@ typedef enum {
     // single quotes and backslashes.
     YP_UNESCAPE_MINIMAL,
 
+    // When we're unescaping a string list, in addition to MINIMAL, we need to
+    // unescape whitespace.
+    YP_UNESCAPE_WHITESPACE,
+
     // When we're unescaping a double-quoted string, we need to unescape all
     // escapes.
-    YP_UNESCAPE_ALL
+    YP_UNESCAPE_ALL,
 } yp_unescape_type_t;
 
 // Unescape the contents of the given token into the given string using the given unescape mode.
diff --git a/src/unescape.c b/src/unescape.c
index 9da52eae..6ecb8f49 100644
--- a/src/unescape.c
+++ b/src/unescape.c
@@ -509,7 +509,17 @@ yp_unescape_manipulate_string_or_char_literal(yp_parser_t *parser, yp_string_t *
                 cursor = backslash + 2;
                 break;
             default:
-                if (unescape_type == YP_UNESCAPE_MINIMAL) {
+                if (unescape_type == YP_UNESCAPE_WHITESPACE) {
+                    if (backslash[1] == '\r' && backslash[2] == '\n') {
+                        cursor = backslash + 2;
+                        break;
+                    }
+                    if (yp_strspn_whitespace(backslash + 1, 1)) {
+                        cursor = backslash + 1;
+                        break;
+                    }
+                }
+                if (unescape_type == YP_UNESCAPE_WHITESPACE || unescape_type == YP_UNESCAPE_MINIMAL) {
                     // In this case we're escaping something that doesn't need escaping.
                     dest[dest_length++] = '\\';
                     cursor = backslash + 1;
@@ -579,7 +589,16 @@ yp_unescape_calculate_difference(yp_parser_t *parser, const uint8_t *backslash,
         case '\'':
             return 2;
         default: {
-            if (unescape_type == YP_UNESCAPE_MINIMAL) {
+            if (unescape_type == YP_UNESCAPE_WHITESPACE) {
+                if (backslash[1] == '\r' && backslash[2] == '\n') {
+                    return 2;
+                }
+                size_t whitespace = yp_strspn_whitespace(backslash + 1, 1);
+                if (whitespace > 0) {
+                    return whitespace;
+                }
+            }
+            if (unescape_type == YP_UNESCAPE_WHITESPACE || unescape_type == YP_UNESCAPE_MINIMAL) {
                 return 1 + yp_char_width(parser, backslash + 1, parser->end);
             }
 
diff --git a/src/yarp.c b/src/yarp.c
index b250220e..65414908 100644
--- a/src/yarp.c
+++ b/src/yarp.c
@@ -12958,7 +12958,7 @@ parse_expression_prefix(yp_parser_t *parser, yp_binding_power_t binding_power) {
 
                 yp_token_t opening = not_provided(parser);
                 yp_token_t closing = not_provided(parser);
-                yp_node_t *string = (yp_node_t *) yp_string_node_create_and_unescape(parser, &opening, &parser->previous, &closing, YP_UNESCAPE_MINIMAL);
+                yp_node_t *string = (yp_node_t *) yp_string_node_create_and_unescape(parser, &opening, &parser->previous, &closing, YP_UNESCAPE_WHITESPACE);
                 yp_array_node_elements_append(array, string);
             }
 


Test patch:
diff --git a/test/yarp/snapshots/spanning_heredoc.txt b/test/yarp/snapshots/spanning_heredoc.txt
index 63a97279..591f4bbf 100644
--- a/test/yarp/snapshots/spanning_heredoc.txt
+++ b/test/yarp/snapshots/spanning_heredoc.txt
@@ -179,7 +179,7 @@
         │   │           │   │   ├── opening_loc: ∅
         │   │           │   │   ├── content_loc: (532...535) = "j\\\n"
         │   │           │   │   ├── closing_loc: ∅
-        │   │           │   │   └── unescaped: "j\\\n"
+        │   │           │   │   └── unescaped: "j\n"
         │   │           │   └── @ StringNode (location: (539...540))
         │   │           │       ├── flags: ∅
         │   │           │       ├── opening_loc: ∅
diff --git a/test/yarp/snapshots/strings.txt b/test/yarp/snapshots/strings.txt
index ef08cb69..0040191d 100644
--- a/test/yarp/snapshots/strings.txt
+++ b/test/yarp/snapshots/strings.txt
@@ -292,7 +292,7 @@
         │   │   │   ├── opening_loc: ∅
         │   │   │   ├── content_loc: (290...298) = "foo\\ bar"
         │   │   │   ├── closing_loc: ∅
-        │   │   │   └── unescaped: "foo\\ bar"
+        │   │   │   └── unescaped: "foo bar"
         │   │   └── @ StringNode (location: (299...304))
         │   │       ├── flags: ∅
         │   │       ├── opening_loc: ∅
@@ -308,7 +308,7 @@
         │   │   │   ├── opening_loc: ∅
         │   │   │   ├── content_loc: (310...318) = "foo\\ bar"
         │   │   │   ├── closing_loc: ∅
-        │   │   │   └── unescaped: "foo\\ bar"
+        │   │   │   └── unescaped: "foo bar"
         │   │   └── @ StringNode (location: (319...322))
         │   │       ├── flags: ∅
         │   │       ├── opening_loc: ∅
diff --git a/test/yarp/snapshots/whitequark/parser_slash_slash_n_escaping_in_literals.txt b/test/yarp/snapshots/whitequark/parser_slash_slash_n_escaping_in_literals.txt
index ae5873a7..ee11d266 100644
--- a/test/yarp/snapshots/whitequark/parser_slash_slash_n_escaping_in_literals.txt
+++ b/test/yarp/snapshots/whitequark/parser_slash_slash_n_escaping_in_literals.txt
@@ -67,7 +67,7 @@
         │   │       ├── opening_loc: ∅
         │   │       ├── content_loc: (81...85) = "a\\\nb"
         │   │       ├── closing_loc: ∅
-        │   │       └── unescaped: "a\\\nb"
+        │   │       └── unescaped: "a\nb"
         │   ├── opening_loc: (78...81) = "%w{"
         │   └── closing_loc: (85...86) = "}"
         ├── @ XStringNode (location: (88...96))
diff --git a/test/yarp/unescape_test.rb b/test/yarp/unescape_test.rb
index f39bdd0e..a7d955b3 100644
--- a/test/yarp/unescape_test.rb
+++ b/test/yarp/unescape_test.rb
@@ -136,6 +136,13 @@ module YARP
       assert_unescape_all("g", "\\g")
     end
 
+    def test_whitespace_escaping_string_list
+      assert_equal("a b", Debug.unescape_whitespace("a\\ b"))
+      assert_equal("a\tb", Debug.unescape_whitespace("a\\\tb"))
+      assert_equal("a\nb", Debug.unescape_whitespace("a\\\nb"))
+      assert_equal("a\nb", Debug.unescape_whitespace("a\\\r\nb"))
+    end
+
     private
 
     def unescape_all(source)