Skip to content

Commit f04527b

Browse files
committed
added custom string tags
1 parent 3257ffd commit f04527b

170 files changed

Lines changed: 1662 additions & 125 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

crates/by_transforms/src/lib.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -566,6 +566,7 @@ pub fn reverse_transpile(source: &str, config: &Config) -> Result<String, String
566566
let mut auto_quote_rev = reverse_transforms::auto_quote::AutoQuoteReverse::new(src);
567567
let mut compat_rev = reverse_transforms::compat::CompatReverse::new();
568568
let mut none_chain_rev = reverse_transforms::none_chain::NoneChainReverse::new(src);
569+
let mut string_tag_rev = reverse_transforms::string_tag::StringTagReverse::new(src);
569570
let mut typing_redirect_rev = reverse_transforms::typing_redirect::TypingRedirectReverse::new();
570571

571572
for stmt in module.suite() {
@@ -587,6 +588,7 @@ pub fn reverse_transpile(source: &str, config: &Config) -> Result<String, String
587588
auto_quote_rev.visit_stmt(stmt);
588589
compat_rev.visit_stmt(stmt);
589590
none_chain_rev.visit_stmt(stmt);
591+
string_tag_rev.visit_stmt(stmt);
590592
// `callable` rewrites callable annotations to the arrow form. it runs
591593
// for stubs too, but in a restricted "stub" mode (set above) that only
592594
// touches the gradual `Callable[..., R]` form — the `Callable[[A, B],
@@ -636,6 +638,7 @@ pub fn reverse_transpile(source: &str, config: &Config) -> Result<String, String
636638
fixes.extend(auto_quote_rev.edits);
637639
fixes.extend(compat_rev.edits);
638640
fixes.extend(none_chain_rev.edits);
641+
fixes.extend(string_tag_rev.edits);
639642
fixes.extend(typing_redirect_rev.edits);
640643

641644
let body = apply_transforms_once(src, fixes).0;

crates/by_transforms/src/reverse_transforms/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ pub(crate) mod none_chain;
2929
pub(crate) mod not_type;
3030
pub(crate) mod overload;
3131
pub(crate) mod prune_imports;
32+
pub(crate) mod string_tag;
3233
pub(crate) mod subscript;
3334
pub(crate) mod super_keyword;
3435
pub(crate) mod tuple_type;
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
//! reverse of `crate::transforms::string_tag`:
2+
//! `tag(t"...")` → `tag"..."`
3+
//!
4+
//! the forward transform lowers an abutting tag `tag"..."` to a call
5+
//! `tag(t"...")` (the native form on 3.14+). the reverse re-sugars that exact
6+
//! shape: a bare-name callee applied to a single positional t-string argument,
7+
//! with no keywords.
8+
//!
9+
//! there is no provenance side-channel in the produced python, so the shape is
10+
//! the only signal that a call originated as a tag. re-sugaring is left off
11+
//! whenever it would not round-trip identically:
12+
//!
13+
//! - a raw t-string (`rt"..."`) can't be re-sugared, since dropping the `t`
14+
//! would leave a raw string, not a tag — the forward transform only ever
15+
//! emits a plain `t"..."`
16+
//! - a callee whose name is itself a builtin string prefix (`f`, `rb`, `t`, …)
17+
//! is left alone: `f(t"x")` must not become `f"x"`, which is an f-string
18+
//!
19+
//! both directions agree on the same boundary, so a forward-then-reverse (or
20+
//! reverse-then-forward) pass is stable.
21+
22+
use ruff_diagnostics::{Edit, Fix};
23+
use ruff_python_ast::visitor::{Visitor, walk_expr, walk_stmt};
24+
use ruff_python_ast::{Expr, ExprCall, Stmt};
25+
use ruff_text_size::{Ranged, TextRange};
26+
27+
pub(crate) struct StringTagReverse<'src> {
28+
source: &'src str,
29+
pub(crate) edits: Vec<Fix>,
30+
}
31+
32+
impl<'src> StringTagReverse<'src> {
33+
pub(crate) fn new(source: &'src str) -> Self {
34+
Self {
35+
source,
36+
edits: Vec::new(),
37+
}
38+
}
39+
40+
fn src(&self, range: TextRange) -> &str {
41+
&self.source[usize::from(range.start())..usize::from(range.end())]
42+
}
43+
44+
/// the re-sugared `tag"..."` text for a call, or `None` if the call is not
45+
/// a round-trippable tag shape
46+
fn resugar(&self, call: &ExprCall) -> Option<String> {
47+
// bare-name callee whose name is not a builtin string prefix
48+
let Expr::Name(name) = call.func.as_ref() else {
49+
return None;
50+
};
51+
if is_builtin_string_prefix(name.id.as_str()) {
52+
return None;
53+
}
54+
// exactly one positional argument, a t-string, no keywords
55+
if !call.arguments.keywords.is_empty() {
56+
return None;
57+
}
58+
let [arg] = call.arguments.args.as_ref() else {
59+
return None;
60+
};
61+
let Expr::TString(tstring) = arg else {
62+
return None;
63+
};
64+
// a single, non-raw t-string part. dropping the `t` prefix leaves the
65+
// bare string the tag abuts
66+
let part = tstring.as_single_part_tstring()?;
67+
if part.flags.prefix().is_raw() {
68+
return None;
69+
}
70+
let literal = self.src(arg.range());
71+
let body = literal
72+
.strip_prefix('t')
73+
.or_else(|| literal.strip_prefix('T'))?;
74+
Some(format!("{}{body}", name.id.as_str()))
75+
}
76+
}
77+
78+
impl<'ast> Visitor<'ast> for StringTagReverse<'_> {
79+
fn visit_stmt(&mut self, stmt: &'ast Stmt) {
80+
walk_stmt(self, stmt);
81+
}
82+
83+
fn visit_expr(&mut self, expr: &'ast Expr) {
84+
if let Expr::Call(call) = expr
85+
&& let Some(replacement) = self.resugar(call)
86+
{
87+
self.edits.push(Fix::safe_edit(Edit::range_replacement(
88+
replacement,
89+
expr.range(),
90+
)));
91+
// descend so a nested tag inside an argument also re-sugars
92+
}
93+
walk_expr(self, expr);
94+
}
95+
}
96+
97+
/// whether `name` is exactly a valid builtin string-prefix combination, which
98+
/// the lexer would read as a string prefix rather than a tag name. mirrors the
99+
/// lexer's `try_single_char_prefix` / `try_double_char_prefix` acceptance
100+
fn is_builtin_string_prefix(name: &str) -> bool {
101+
let mut chars = name.chars();
102+
match (chars.next(), chars.next(), chars.next()) {
103+
(Some(a), None, _) => {
104+
matches!(a, 'f' | 'F' | 't' | 'T' | 'u' | 'U' | 'b' | 'B' | 'r' | 'R')
105+
}
106+
(Some(a), Some(b), None) => {
107+
let lower = (a.to_ascii_lowercase(), b.to_ascii_lowercase());
108+
matches!(lower, ('r', 'f' | 't' | 'b') | ('f' | 't' | 'b', 'r'))
109+
}
110+
_ => false,
111+
}
112+
}
113+
114+
#[cfg(test)]
115+
mod tests {
116+
use crate::{Config, reverse_transpile};
117+
118+
fn check(input: &str, expected: &str) {
119+
assert_eq!(
120+
reverse_transpile(input, &Config::test_default()).unwrap(),
121+
expected
122+
);
123+
}
124+
125+
#[test]
126+
fn non_interpolating() {
127+
check("a = greet(t\"hello\")\n", "a = greet\"hello\"\n");
128+
}
129+
130+
#[test]
131+
fn interpolating() {
132+
check("b = greet(t\"hi {name}\")\n", "b = greet\"hi {name}\"\n");
133+
}
134+
135+
#[test]
136+
fn multiple_fields() {
137+
check(
138+
"q = sql(t\"select {a} from {b}\")\n",
139+
"q = sql\"select {a} from {b}\"\n",
140+
);
141+
}
142+
143+
// a builtin-prefix-named callee must not be re-sugared: `f(t"x")` stays a
144+
// call, since `f"x"` would be an f-string
145+
#[test]
146+
fn builtin_prefix_callee_unchanged() {
147+
check("f(t\"x\")\n", "f(t\"x\")\n");
148+
check("rb(t\"x\")\n", "rb(t\"x\")\n");
149+
}
150+
151+
// a raw t-string can't be re-sugared (dropping `t` would leave a raw string)
152+
#[test]
153+
fn raw_tstring_unchanged() {
154+
check("q = sql(rt\"raw\")\n", "q = sql(rt\"raw\")\n");
155+
}
156+
157+
// a call with extra arguments is not a tag shape
158+
#[test]
159+
fn extra_argument_unchanged() {
160+
check("q = sql(t\"x\", 1)\n", "q = sql(t\"x\", 1)\n");
161+
}
162+
163+
// a plain string argument (not a t-string) is not a tag
164+
#[test]
165+
fn plain_string_unchanged() {
166+
check("q = sql(\"x\")\n", "q = sql(\"x\")\n");
167+
}
168+
169+
// round-trip stability: re-sugared output forward-transpiles back to the
170+
// same call shape (checked at 3.14 for the native form)
171+
#[test]
172+
fn round_trips_through_forward() {
173+
use crate::{PythonVersion, transpile};
174+
let py = "q = sql(t\"select {x}\")\n";
175+
let by = reverse_transpile(py, &Config::test_default()).unwrap();
176+
assert_eq!(by, "q = sql\"select {x}\"\n");
177+
let config = Config {
178+
min_version: PythonVersion::PY314,
179+
..Config::test_default()
180+
};
181+
assert_eq!(transpile(&by, &config).unwrap(), py);
182+
}
183+
}

crates/by_transforms/src/transforms/ast_driver.rs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,8 @@ use super::{
3939
float_const, force_unwrap, generic_call, generics, identity_swap, implicit_typing, init_method,
4040
intersection, just_float, kw_subscript, literal_types, main_function, modifiers,
4141
mutable_defaults, none_chain, not_type, optional_type, overload, postfix_await, propagate,
42-
repeated_underscore, sentinel, some_ctor, super_keyword, symbolic_type_op, top_star,
43-
tuple_index, type_is, typed_dict_literal, typed_lambda, typeof_keyword, unpack,
42+
repeated_underscore, sentinel, some_ctor, string_tag, super_keyword, symbolic_type_op,
43+
top_star, tuple_index, type_is, typed_dict_literal, typed_lambda, typeof_keyword, unpack,
4444
use_site_variance,
4545
};
4646
use crate::Config;
@@ -432,6 +432,7 @@ pub(crate) fn run_against_source<'a>(
432432
let top_star_pass = top_star::TopStar::new();
433433
let identity_swap_pass = identity_swap::IdentitySwap::new(source_ref);
434434
let compat_pass = compat::CompatRewrite::new(source_ref, config.clone());
435+
let string_tag_pass = string_tag::StringTagPass::new(source_ref, config.clone());
435436
let dedent_string_pass = dedent_string::DedentString::new(source_ref);
436437
let super_keyword_pass = super_keyword::SuperKeyword::new();
437438
let postfix_await_pass = postfix_await::PostfixAwait::new(source_ref);
@@ -483,6 +484,10 @@ pub(crate) fn run_against_source<'a>(
483484
&top_star_pass,
484485
&identity_swap_pass,
485486
&compat_pass,
487+
// a custom string tag wraps a template literal whose interpolations may
488+
// themselves lower; its template-edit passes interpolation source
489+
// through as `Src` fragments so those inner edits still compose
490+
&string_tag_pass,
486491
&dedent_string_pass,
487492
&super_keyword_pass,
488493
&postfix_await_pass,

crates/by_transforms/src/transforms/identity_swap.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ fn isinstance_call(lhs: Expr, rhs: Expr, negate: bool) -> Expr {
102102
keywords: Box::new([]),
103103
},
104104
is_cast: false,
105+
is_string_tag: false,
105106
});
106107
if negate {
107108
Expr::UnaryOp(ExprUnaryOp {

crates/by_transforms/src/transforms/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ pub(crate) mod repeated_underscore;
3838
pub(crate) mod sentinel;
3939
pub(crate) mod some_ctor;
4040
pub(crate) mod source_util;
41+
pub(crate) mod string_tag;
4142
pub(crate) mod super_keyword;
4243
pub(crate) mod symbolic_type_op;
4344
pub(crate) mod top_star;

crates/by_transforms/src/transforms/sentinel.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ impl Transformer for Sentinel {
8282
keywords: Box::new([]),
8383
},
8484
is_cast: false,
85+
is_string_tag: false,
8586
});
8687
*stmt = Stmt::Assign(StmtAssign {
8788
node_index: AtomicNodeIndex::NONE,

0 commit comments

Comments
 (0)