Repository: xonsh/xonsh

Issue:
Add pf-strings
Add support for pf-strings. That is, an f-string that is cast to a `Path`.

Discussion:

Patch:
diff --git a/news/pf_strings.rst b/news/pf_strings.rst
new file mode 100644
index 00000000..d7871fe3
--- /dev/null
+++ b/news/pf_strings.rst
@@ -0,0 +1,34 @@
+**Added:**
+
+* New xonsh syntax ``pf`` strings -- combining path strings with f-strings.
+
+  Usage:
+
+  .. code-block:: bash
+       gil@bad_cat ~ $ repos = 'github.com'
+       gil@bad_cat ~ $ pf"~/{repos}"
+       PosixPath('/home/gil/github.com')
+       gil@bad_cat ~ $ pf"{$HOME}"
+       PosixPath('/home/gil')
+       gil@bad_cat ~ $ pf"/home/${'US' + 'ER'}"
+       PosixPath('/home/gil')
+
+**Changed:**
+
+* <news item>
+
+**Deprecated:**
+
+* <news item>
+
+**Removed:**
+
+* <news item>
+
+**Fixed:**
+
+* <news item>
+
+**Security:**
+
+* <news item>
diff --git a/xonsh/parsers/base.py b/xonsh/parsers/base.py
index 06ba6806..d14842aa 100644
--- a/xonsh/parsers/base.py
+++ b/xonsh/parsers/base.py
@@ -2422,8 +2422,18 @@ class BaseParser(object):
     def p_string_literal(self, p):
         """string_literal : string_tok"""
         p1 = p[1]
-        prefix = RE_STRINGPREFIX.match(p1.value).group()
-        if "p" in prefix:
+        prefix = RE_STRINGPREFIX.match(p1.value).group().lower()
+        if "p" in prefix and "f" in prefix:
+            new_pref = prefix.replace("p", "")
+            value_without_p = new_pref + p1.value[len(prefix):]
+            s = eval_fstr_fields(value_without_p, new_pref, filename=self.lexer.fname)
+            s = pyparse(s).body[0].value
+            s = ast.increment_lineno(s, p1.lineno - 1)
+            p[0] = xonsh_call(
+                "__xonsh__.path_literal", [s], lineno=p1.lineno, col=p1.lexpos
+            )
+
+        elif "p" in prefix:
             value_without_p = prefix.replace("p", "") + p1.value[len(prefix) :]
             s = ast.Str(
                 s=ast.literal_eval(value_without_p),
@@ -2433,14 +2443,14 @@ class BaseParser(object):
             p[0] = xonsh_call(
                 "__xonsh__.path_literal", [s], lineno=p1.lineno, col=p1.lexpos
             )
-        elif "f" in prefix or "F" in prefix:
+        elif "f" in prefix:
             s = eval_fstr_fields(p1.value, prefix, filename=self.lexer.fname)
             s = pyparse(s).body[0].value
             s = ast.increment_lineno(s, p1.lineno - 1)
             p[0] = s
         else:
             s = ast.literal_eval(p1.value)
-            is_bytes = "b" in prefix or "B" in prefix
+            is_bytes = "b" in prefix
             cls = ast.Bytes if is_bytes else ast.Str
             p[0] = cls(s=s, lineno=p1.lineno, col_offset=p1.lexpos)
 
diff --git a/xonsh/tokenize.py b/xonsh/tokenize.py
index a066b0b1..dc25c6b9 100644
--- a/xonsh/tokenize.py
+++ b/xonsh/tokenize.py
@@ -273,7 +273,7 @@ Floatnumber = group(Pointfloat, Expfloat)
 Imagnumber = group(r"[0-9](?:_?[0-9])*[jJ]", Floatnumber + r"[jJ]")
 Number = group(Imagnumber, Floatnumber, Intnumber)
 
-StringPrefix = r"(?:[bBp][rR]?|[rR][bBpfF]?|[uU]|[fF][rR]?)?"
+StringPrefix = r"(?:[bB][rR]?|[p][fFrR]?|[rR][bBpfF]?|[uU]|[fF][rR]?[p]?)?"
 
 # Tail end of ' string.
 Single = r"[^'\\]*(?:\\.[^'\\]*)*'"
@@ -394,6 +394,8 @@ endpats = {
     'br"""': Double3,
     "fr'''": Single3,
     'fr"""': Double3,
+    "fp'''": Single3,
+    'fp"""': Double3,
     "bR'''": Single3,
     'bR"""': Double3,
     "Br'''": Single3,
@@ -408,6 +410,8 @@ endpats = {
     'Rb"""': Double3,
     "Fr'''": Single3,
     'Fr"""': Double3,
+    "Fp'''": Single3,
+    'Fp"""': Double3,
     "rB'''": Single3,
     'rB"""': Double3,
     "rF'''": Single3,
@@ -424,6 +428,10 @@ endpats = {
     'p"""': Double3,
     "pr'''": Single3,
     'pr"""': Double3,
+    "pf'''": Single3,
+    'pf"""': Double3,
+    "pF'''": Single3,
+    'pF"""': Double3,
     "pR'''": Single3,
     'pR"""': Double3,
     "rp'''": Single3,
@@ -503,6 +511,14 @@ for t in (
     'rp""""',
     "Rp'''",
     'Rp""""',
+    "pf'''",
+    'pf""""',
+    "pF'''",
+    'pF""""',
+    "fp'''",
+    'fp""""',
+    "Fp'''",
+    'Fp""""',
 ):
     triple_quoted[t] = t
 single_quoted = {}
@@ -567,6 +583,14 @@ for t in (
     'rp"',
     "Rp'",
     'Rp"',
+    "pf'",
+    'pf"',
+    "pF'",
+    'pF"',
+    "fp'",
+    'fp"',
+    "Fp'",
+    'Fp"',
 ):
     single_quoted[t] = t
 


Test patch:
diff --git a/tests/test_lexer.py b/tests/test_lexer.py
index 9b84e098..9c9b5746 100644
--- a/tests/test_lexer.py
+++ b/tests/test_lexer.py
@@ -384,6 +384,17 @@ def test_path_string_literal():
     assert check_token('rp"/foo"', ["STRING", 'rp"/foo"', 0])
 
 
+def test_path_fstring_literal():
+    assert check_token("pf'/foo'", ["STRING", "pf'/foo'", 0])
+    assert check_token('pf"/foo"', ["STRING", 'pf"/foo"', 0])
+    assert check_token("fp'/foo'", ["STRING", "fp'/foo'", 0])
+    assert check_token('fp"/foo"', ["STRING", 'fp"/foo"', 0])
+    assert check_token("pF'/foo'", ["STRING", "pF'/foo'", 0])
+    assert check_token('pF"/foo"', ["STRING", 'pF"/foo"', 0])
+    assert check_token("Fp'/foo'", ["STRING", "Fp'/foo'", 0])
+    assert check_token('Fp"/foo"', ["STRING", 'Fp"/foo"', 0])
+
+
 def test_regex_globs():
     for i in (".*", r"\d*", ".*#{1,2}"):
         for p in ("", "r", "g", "@somethingelse", "p", "pg"):
diff --git a/tests/test_parser.py b/tests/test_parser.py
index 0cea6d56..ae0ad74a 100644
--- a/tests/test_parser.py
+++ b/tests/test_parser.py
@@ -2042,6 +2042,17 @@ def test_path_literal():
     check_xonsh_ast({}, 'Rp"/foo"', False)
 
 
+def test_path_fstring_literal():
+    check_xonsh_ast({}, 'pf"/foo"', False)
+    check_xonsh_ast({}, 'fp"/foo"', False)
+    check_xonsh_ast({}, 'pF"/foo"', False)
+    check_xonsh_ast({}, 'Fp"/foo"', False)
+    check_xonsh_ast({}, 'pf"/foo{1+1}"', False)
+    check_xonsh_ast({}, 'fp"/foo{1+1}"', False)
+    check_xonsh_ast({}, 'pF"/foo{1+1}"', False)
+    check_xonsh_ast({}, 'Fp"/foo{1+1}"', False)
+
+
 def test_dollar_name():
     check_xonsh_ast({"WAKKA": 42}, "$WAKKA")