RFC: Interpolated string function calls#170
RFC: Interpolated string function calls#170rofrankel wants to merge 5 commits intoluau-lang:masterfrom
Conversation
Add support for calling functions with interpolated strings without parentheses, enabling DSL patterns like structured logging where the template, interpolation values, and optional context are passed to the function. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
|
Edit: This pattern is now described in this RFC (not as a core part of the RFC, just as an illustration of how it might be used). For completeness, one alternative proposal that came up (I can flesh this out into a separate RFC if helpful): We could create a new type Message = {
template: string,
context: { [string]: any },
-- Renders `template` using `context`
toString: (self: Message) -> string
}Primary functional differences from this RFC include:
Note: there are things I like about this approach, but I can't take credit for the idea - it was suggested by a colleague during a brainstorming session. |
|
The arguments passed to the function "called" in this way seem unintuitive, and also very specific. For this to be used in a DSL-y way (or even just by a fancy logger) it would also require that function to itself handle parsing of the Would an approach like JavaScripts template literal tag functions make more sense? When you call log_info`Hello {name}!`in JavaScript, it would pass the In a luau-y way this might be better translated as taking two arguments, the first being a table of the string chunks (N values, N>=1) and the second being a table of the values to interpolate (N-1 values). Edit: I just noticed this JS way of doing things was mentioned as a rejected alternative. The rejection reasons don't make sense to me. Not providing the original template string is good and honestly necessary if you want any hope of this API being possible without implementing an entire luau parser, for the reasons outlined below. A table mapping for names to values could be easily added if really desired, though I can't think of many instances outside of debuggers where it ends up necessary to know the names (as opposed to confusing, when the same template is being used but provided different locals for values). The third rejection reason makes no sense at all; JS's "arrays" are the same as luau "tables". If multiple-arguments is the actual concern, and just poorly worded, I already addressed that here. As an example of where this makes way more sense, the RFC suggests:
To me this is absolutely not what you'd want, because now
which suggests any consumer of this API would need to implement an entire luau parser to have any hope of reconstructing the desired string output! This conundrum also then causes seemingly nonsensical restrictions being applied to the API such as just below where we see the print(dsl`Something { dsl`Something else` }`) to be illegal, but one might expect many DSLs would require (or at least be made substantially nicer with) nesting. Just so this comment isn't all doom and gloom, I think one approach that could be more ergonomic nice is a With that, log "hello world"and log `hello world`would function identically unless the body of |
hgoldstein
left a comment
There was a problem hiding this comment.
The main sticking points here are:
- The ambiguity noted by the extra parameter(s);
- The restrictions on what can be passed to interpolated strings.
| When a function is called with an interpolated string literal in this style, the call receives multiple arguments derived from the interpolated string: | ||
|
|
||
| 1. **Formatted string**: The fully interpolated result (what you would get from the expression today) | ||
| 2. **Template string**: The original template with placeholders intact, e.g. `"Hello {name}"` |
There was a problem hiding this comment.
As is, the way string interpolation appears to work is that we de-sugar:
print(`Hello {name}, it is {currtime}!`)... to something like ...
print(string.format("Hello %*, it is %*!", name, currtime))I'm imagining semantics like:
template `Hello {name}, it is {currtime}`... desugaring to something like ...
-- Could also be an array for the second argument
template("Hello %*, it is %*!", name, currtime)Would that be enough for your use case? I think it also makes the "mistaken" case of ...
print `Hello, my name is {name}`... a little less unfortunate.
There was a problem hiding this comment.
Personally me one issue with this is you still end up needing to write your own parser to consume this API. It's not too bad, but you can't just do a simple find and replace because you need to account for %-escaping in the passed format string. I think you could cook up a nasty gsub using "([^%%])%%%*", "%1" .. replace with an extra check for if the %* is at the start of the string, but this feels pretty ugly to me.
There was a problem hiding this comment.
Yeah there's a couple decent outcomes here. My immediate thought, well before I read the RFC, is you'd get the "decomposed" interpolation, so an array of alternating string and value parts, e.g.:
log `Foo {name} bar {1 + 2}`... becomes something like ...
log({"Foo ", name, " bar ", 3 })I'll admit wanting to preserve the exact string came out of one of the goals of logging. Another option is that we can probably embed the actual text of the interpolated string, e.g. you'll get:
log `Foo {name} bar {1 + 2}`... becoming ...
log("Foo {name} bar {1 + 2}", "Foo ", name, " bar ", 3 }).... you can still reconstruct it by table.concating everything but the first argument.
There was a problem hiding this comment.
From live discussion with Hunter, perhaps something like:
local a = 1
local function double(x: number)
return x * 2
end
-- Desugars to `log:Info("The double of %* is %*", {1, 2}, {"a", "double(a)"})`
log:Info "The double of {a} is {double(a)}"`There was a problem hiding this comment.
I've updated the draft, PTAL!
|
In response to recent suggestions, I definitely don't want to have to parse out format strings (%*). One interesting thing that brings up though is that if we ever let you provide format specifiers, e.g. |
Address reviewer comments by redesigning the desugaring approach: - Use positional tables (format string with %s, values table, expressions table) instead of named interpolation table, eliminating the need for consumers to parse Luau expressions - Remove expression restrictions: function calls, method calls, and repeated expressions are now all valid - Remove optional trailing context table from grammar; show currying pattern instead for passing additional context - Add SQL escaping and HTML templating motivation examples - Add Message wrapper type for bridging to existing functions - Expand alternatives section with rejected design rationale Co-authored-by: Cursor <cursoragent@cursor.com>
|
I've updated this RFC with a substantially rewritten and simplified approach addressing feedback from @MagmaBurnsV, @Cooldude2606, @hgoldstein, and @Bottersnike. Thanks to @hgoldstein for the live brainstorming. I am much happier with this version than the original version I wrote up (which came out of a 30 minute group brainstorming session that perhaps needed to be longer :) ). The latest draft doesn't directly address @Kampfkarren's comment about potential future support for format specifiers, but perhaps it does so implicitly? E.g. if the |
|
One alternative not mentioned is that you can get pretty close (albeit not with a nice interpolated string) to this behaviour just using existing functionality: local function Log(fmt: string)
local function log_handler(args: { any })
print("Log event. Format string:", fmt, "Evaluated:", string.format(fmt :: any, table.unpack(args)))
end
return log_handler
end
local user = "Bottersnike"
Log "Hello, %*" { user }I like the changes, though
While not an issue directly with the RFC or it's wording, it's worth noting that updating a function isn't possible in many cases. For example, |
…back Co-authored-by: Cursor <cursoragent@cursor.com>
Good call, updated.
Yes this is a valid point - that's why I describe the |
|
|
||
| This was considered and the proposed design shares the same spirit: decomposing the interpolated string into parts that don't require the consumer to parse Luau expressions. The proposed design differs in using a format string with `%*` placeholders instead of a string parts array, and in providing an additional expressions table with the source text of each interpolated expression. The format string approach: | ||
|
|
||
| 1. Provides a single template string usable as a log aggregation key or cache key |
There was a problem hiding this comment.
I think we discussed it offline, but wouldn't it be enough to (effectively) reconstruct the format string by doing something like:
local function mylog(templateparts, values)
local fmt = table.concat(templateparts, "%*")
print(string.format(fmt :: any, table.unpack(values)))
end... including metadata about the values in the template aside.
There was a problem hiding this comment.
This kind of works, but with two drawbacks:
- There's collision risk for simple templates that have the same overall structure, e.g.
{foo}: {bar}and{baz}: {qux}. False positives for aggregation/deduplication may in some cases be even worse than false negatives. - Replacing the placeholder expressions with just
"%*"may hurt readability...in some cases it may be obvious what the placeholders were, but in other cases, less so.
Speaking purely as the user, these drawbacks seem significant enough that I'd rather have the placeholder expressions as well. And users who are happy without the expressions can just ignore them. I know there may be some performance hit, but as we discussed, all the identified realistic use cases for this functionality are likely going to do something much more expensive anyway with the result (e.g. make a network request), so the language performance cost is not a primary concern.
|
This new draft addresses the issues I had, although a new one has arisen. A clarification / brief note on how literals and whitespare are past into the third argument should be included: log `{42}`
-- this one is trivial
-- log("%*", { 42 }, { "42" })
log `{"Alice"}`
-- this one should note how escapes are handled
-- log("%*", { "Alice" }, { "\"Alice\"" })
log `{'Alice'}`
-- are single quotes maintained or converted to double quotes?
-- log("%*", { "Alice" }, { "\'Alice\'" })
-- log("%*", { "Alice" }, { "\"Alice\"" })
log `{ { ["foo"] = "bar", } }`
-- are spaces, quotes and commas maintained or trimed?
-- log("%*", { { "foo" } }, { "{foo=\"bar\"}" })
-- log("%*", { { "foo" } }, { " { [\"foo\"] = \"bar\", } " })
log `{
user . name
}`
-- are spaces and new lines maintained or trimed?
-- log("%*", { "Alice" }, { "user.name" }
-- log("%*", { "Alice" }, { "\n\tuser . name\n" } |
Specify that entries are verbatim source text with leading/trailing whitespace trimmed. Quoting style, internal spacing, and formatting are preserved as written. Addresses reviewer question about literals and whitespace. Co-authored-by: Cursor <cursoragent@cursor.com>
Thanks, good point, updated. |
Co-authored-by: Cursor <cursoragent@cursor.com>
|
|
||
| The template string enables aggregating logs by message pattern (e.g. grouping all "The double of %* is %*" messages), while the values and expression names provide searchable structured data for platforms like Splunk, Datadog, or Elasticsearch. | ||
|
|
||
| Without this feature, developers must either manually construct all arguments (tedious and error-prone) or use a logging library that implements its own template parsing at runtime (duplicating language functionality). |
There was a problem hiding this comment.
... use a logging library that implements its own template parsing at runtime (duplicating language functionality).
I'm not sure that duplicating language functionality is the issue here: it's more that this is also error prone in its own way. For example:
log:Info("Hello, my name is: {{nome}}", { name = "Hunter" })... whereas we already have a mechanism for constructing strings that can provide analysis input (intellisense, error checking): it's more missed opportunity than duplicating language features being a problem.
|
Would it be better (or worse?) if the desugaring instead included byte offsets into the original string for each substitution? log`{timestamp}: Count is {count}. Total is {count + total}`
-- desugars to
log("{timestamp}: Count is {count}. Total is {count + total}", {timestamp, count, count + total},
-- (offset, length) pairs for each substitution
{{0, 11}, {22, 7}, {45, 15}}
)This trivializes any custom parsing you might want to do. |
I like this interface quite a lot. I'd like to see this approach added to the "Alternatives" section alongside a strong argument for why it is unviable. |
Summary
Allow calling functions with interpolated strings without parentheses, enabling DSL patterns like structured logging where the template, interpolation values, and optional context are passed to the function.
Motivation
The string interpolation RFC noted the restriction on parentheses-free calls was "likely temporary while we work through string interpolation DSLs." This RFC proposes lifting that restriction with semantics designed for structured logging and similar use cases.
Key design decisions
Related