Skip to content

fix: preserve the :format suffix on interpolations in fmt - #6218

Merged
max-sixty merged 2 commits into
mainfrom
fix/fmt-preserve-interpolation-format
Aug 22, 2026
Merged

fix: preserve the :format suffix on interpolations in fmt#6218
max-sixty merged 2 commits into
mainfrom
fix/fmt-preserve-interpolation-format

Conversation

@prql-bot

@prql-bot prql-bot commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator

display_interpolation destructured InterpolateItem::Expr { expr, .. }, discarding the format field the parser fills in from a {expr:format} suffix — so fmt rewrote s"MIN({column:0})" as s"MIN({column})". That suffix is not decoration: for s-strings it's the required binding strength of the interpolated operand, which the SQL backend reads in sql/operators.rs to decide how to parenthesize the argument. Dropping it changes generated SQL rather than just the source text.

The fix writes the suffix back out when it's present. The parser reads a format as "everything up to the closing brace" (interpolation.rs), so a format containing braces round-trips verbatim. The escapes the lexer already removed do have to be re-applied though — a " written back raw terminates the string, and a \ is swallowed on the next parse — so the suffix is written with the same \/" escaping as the literal parts.

Verified against prqlc/prqlc/src/sql/std.sql.prql, which carries 144 :0 binding strengths: before the fix prqlc fmt stripped all 144, after it preserves all 144.

Reproduction and verification

Before, on a copy of the dialect stdlib:

$ cp prqlc/prqlc/src/sql/std.sql.prql /tmp/std.sql.prql
$ cargo run -q -p prqlc -- fmt /tmp/std.sql.prql
$ grep -c ':0}' prqlc/prqlc/src/sql/std.sql.prql   # 144
$ grep -c ':0}' /tmp/std.sql.prql                  # 0

let min = func column -> s"MIN({column:0})" became let min = func column -> s"MIN({column})", which resolves the operand at the enclosing function's default binding strength instead of 0.

After the fix, both counts are 144, and the affected lines survive intact:

let min = func column -> s"MIN({column:0})"
let log = func base column -> s"LOG10({column:0}) / LOG10({base:0})"
let to_text = func format column -> s"FORMAT_TIMESTAMP({format:0}, CAST({column:0} AS TIMESTAMP))"

The fmt idempotency assertion added in #6199 can't catch this: dropping the suffix is stable across a second pass, and no integration query uses a format spec.

Regression test test_interpolation_format_is_preserved covers the s-string binding-strength form and the f-string {a:>10} form; it fails on main and passes here.

cargo test -p prqlc -p prqlc-parser is green, with no snapshot churn. task prqlc:pull-request couldn't run in the sandbox — cargo-insta isn't on the path there, which is what #6144 addresses — so CI is the first full-matrix run.

@prql-bot prql-bot left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewing my own PR, so this is a COMMENT rather than an approval.

The change itself is right, and the binding-strength reasoning holds up — translate_operator_expr in sql/operators.rs parses the suffix into required_strength and falls back to the parent strength when it's absent, so dropping it really did change generated SQL rather than just source text.

One defect in the new code: the format is written back verbatim, but the lexer strips string escapes before the interpolation parser ever sees the text, so a " or a \ inside a format is re-emitted raw. The comment's claim that the suffix "needs no escaping" is only true of braces.

in:   derive x = f"{a:\"q\"}"
fmt:  derive x = f"{a:"q"}"     -> second pass: expected something else or '}', but found end of input
in:   derive x = f"{a:\\}"
fmt:  derive x = f"{a:\}"       -> second pass: f"{a:}", format silently emptied

The first case means fmt can emit source that no longer parses; the second breaks the idempotency property #6199 added, for the same class of input the rest of this PR is about. Braces are the genuine exception — the parser reads the format as none_of('}') with no unescaping, so f"{a:{}" round-trips as-is and must not be doubled.

Pushing the one-line fix and test cases for all three shapes, since this is a bot PR with no author to act on the suggestion. cargo test -p prqlc -p prqlc-parser stays green with no snapshot churn.

Comment thread prqlc/prqlc/src/codegen/ast.rs Outdated
The lexer strips string escapes before the interpolation parser runs, so writing the format back out verbatim re-emitted a raw `"` or `\` — output that either no longer parses or loses the escape on the next pass. Braces stay unescaped: the parser reads the format up to the closing brace without unescaping, so `f"{a:{}"` round-trips as-is.
@max-sixty
max-sixty merged commit 6bccb56 into main Aug 22, 2026
38 checks passed
@max-sixty
max-sixty deleted the fix/fmt-preserve-interpolation-format branch August 22, 2026 05:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants