Correct escaping in snippets (#14912)

Luke Naylor and Piotr Osiewicz created

## Release Notes:

- Fixed issue with backslashes not appearing in snippets
([#14721](https://github.com/zed-industries/zed/issues/14721)),
motivated by a snippet provided by the latex LSP
([texlab](https://github.com/latex-lsp/texlab)) not working as intended
in Zed ([extension
issue](https://github.com/rzukic/zed-latex/issues/5)).

[Screencast from 2024-07-21
14-57-19.webm](https://github.com/user-attachments/assets/3c95a987-16e5-4132-8c96-15553966d4ac)

## Fix details:

Only $, }, \ can be escaped by a backslash as per [LSP spec (under
grammar
section)](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/\#snippet_syntax).
Technically, commas and pipes can also be escaped only in "choice"
tabstops but it does not look like they are implemented in Zed yet.

## Additional tests added for cases currently not covered:
- backslash not being used to escape anything (so just a normal
backslash)
- backslash escaping a backslash (so that the second does not escape
what follows it)

---------

Co-authored-by: Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com>

Change summary

crates/snippet/src/snippet.rs | 25 +++++++++++++++++++++++--
1 file changed, 23 insertions(+), 2 deletions(-)

Detailed changes

crates/snippet/src/snippet.rs 🔗

@@ -47,10 +47,20 @@ fn parse_snippet<'a>(
                 source = parse_tabstop(&source[1..], text, tabstops)?;
             }
             Some('\\') => {
+                // As specified in the LSP spec (`Grammar` section),
+                // backslashes can escape some characters:
+                // https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#snippet_syntax
                 source = &source[1..];
                 if let Some(c) = source.chars().next() {
-                    text.push(c);
-                    source = &source[c.len_utf8()..];
+                    if c == '$' || c == '\\' || c == '}' {
+                        text.push(c);
+                        // All escapable characters are 1 byte long:
+                        source = &source[1..];
+                    } else {
+                        text.push('\\');
+                    }
+                } else {
+                    text.push('\\');
                 }
             }
             Some('}') => {
@@ -197,6 +207,17 @@ mod tests {
         let snippet = Snippet::parse("{a\\}").unwrap();
         assert_eq!(snippet.text, "{a}");
         assert_eq!(tabstops(&snippet), &[vec![3..3]]);
+
+        // backslash not functioning as an escape
+        let snippet = Snippet::parse("a\\b").unwrap();
+        assert_eq!(snippet.text, "a\\b");
+        assert_eq!(tabstops(&snippet), &[vec![3..3]]);
+
+        // first backslash cancelling escaping that would
+        // have happened with second backslash
+        let snippet = Snippet::parse("one\\\\$1two").unwrap();
+        assert_eq!(snippet.text, "one\\two");
+        assert_eq!(tabstops(&snippet), &[vec![4..4], vec![7..7]]);
     }
 
     fn tabstops(snippet: &Snippet) -> Vec<Vec<Range<isize>>> {