-
Notifications
You must be signed in to change notification settings - Fork 89
RFC: Interpolated string function calls #170
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from 5 commits
6bad10a
2458396
0aa415c
1e3fce8
3820133
629f688
7ba79c3
8e31abf
ca451e1
f8875f5
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,331 @@ | ||
| # Interpolated string function calls | ||
|
|
||
| ## Summary | ||
|
|
||
| Allow calling functions with interpolated string literals without parentheses. The call is desugared into a function call with three arguments: a format string with `%*` placeholders, a table of the evaluated interpolation values, and a table of the original expression source texts. This enables domain-specific language patterns like structured logging, SQL escaping, and HTML templating. | ||
|
|
||
| ## Motivation | ||
|
|
||
| Luau currently supports function calls without parentheses for string literals and table literals: | ||
|
|
||
| ```luau | ||
| print "hello" -- equivalent to print("hello") | ||
| print { 1, 2, 3 } -- equivalent to print({1, 2, 3}) | ||
| ``` | ||
|
|
||
| When string interpolation was introduced, this calling style was explicitly prohibited for interpolated strings: | ||
|
|
||
| ```luau | ||
| local name = "world" | ||
| print `Hello {name}` -- currently a parse error | ||
| ``` | ||
|
|
||
| The [string interpolation RFC](https://github.com/luau-lang/rfcs/blob/master/docs/syntax-string-interpolation.md) noted this restriction was "likely temporary while we work through string interpolation DSLs." | ||
|
|
||
| This proposal lifts that restriction with semantics that decompose the interpolated string into its constituent parts, passing them to the called function. This enables the function to process the template, values, and expression metadata however it sees fit, without needing to implement its own string parser. | ||
|
|
||
| ### Structured logging | ||
|
|
||
| The primary motivating use case is structured logging, where it is valuable to capture both the formatted message template and its interpolation values for machine processing: | ||
|
|
||
| ```luau | ||
| local a = 1 | ||
| local function double(x: number) | ||
| return x * 2 | ||
| end | ||
|
|
||
| -- Desugars to `log:Info("The double of %* is %*", {a, double(a)}, {"a", "double(a)"})` | ||
| -- Note that the second argument evalutes to {1, 2} at runtime, but not at the desugaring step. | ||
| log:Info `The double of {a} is {double(a)}` | ||
| ``` | ||
|
|
||
| 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). | ||
|
|
||
| ### SQL escaping | ||
|
|
||
| Interpolated string calls enable safe, ergonomic SQL query construction where the function can automatically escape interpolated values: | ||
|
|
||
| ```luau | ||
| local sqlite = require("luau_sqlite") | ||
|
|
||
| local function takeUserInput(db: sqlite.DB, user: string, comment: string) | ||
| -- Auto-escape inputs to guard against SQL injection | ||
| db:Exec `INSERT INTO user_inputs (user, comment) VALUES ({user}, {comment})` | ||
| -- Desugars to: db:Exec("INSERT INTO user_inputs (user, comment) VALUES (%*, %*)", {user, comment}, {"user", "comment"}) | ||
| end | ||
| ``` | ||
|
|
||
| ### HTML templating | ||
|
|
||
| Similarly, HTML renderers can automatically escape interpolated values to prevent XSS attacks: | ||
|
|
||
| ```luau | ||
| local tmpl = require("luau_html_renderer") | ||
|
|
||
| local function renderPage(r: tmpl.Renderer, userinput: string) | ||
| -- Automatic XSS protection through escaping | ||
| return tmpl.HTML `The user asked about {userinput}` | ||
| -- Desugars to: tmpl.HTML("The user asked about %*", {userinput}, {"userinput"}) | ||
| end | ||
| ``` | ||
|
|
||
| ## Design | ||
|
|
||
| ### Grammar | ||
|
|
||
| The grammar for function calls is extended to allow an interpolated string as the argument: | ||
|
|
||
| ``` | ||
| functioncall ::= prefixexp args | ||
| args ::= '(' [explist] ')' | tableconstructor | LiteralString | stringinterp | ||
| ``` | ||
|
|
||
| ### Semantics | ||
|
|
||
| When a function is called with an interpolated string literal in this style, the compiler desugars the call into a function call with three arguments: | ||
|
|
||
| 1. **Format string**: The template with each `{expression}` replaced by `%*`, e.g. `"The double of %* is %*"` | ||
| 2. **Values table**: A sequential table of the interpolation values, e.g. `{a, double(a)}` | ||
| 3. **Expressions table**: A sequential table of the original expression source texts, e.g. `{"a", "double(a)"}` | ||
|
|
||
| The format string and expressions table are determined at compile time. The values table is evaluated at runtime. | ||
|
|
||
| Each entry in the expressions table is the verbatim source text between the `{` and `}` delimiters, with leading and trailing whitespace trimmed. Quoting style, internal spacing, and other formatting are preserved as written: | ||
|
|
||
| ```luau | ||
| log `{ user.name }` | ||
| -- Expressions table: {"user.name"} (leading/trailing whitespace trimmed) | ||
|
|
||
| log `{"Alice"}` | ||
| -- Expressions table: {"\"Alice\""} (source text preserved, including quotes) | ||
|
|
||
| log `{ { ["foo"] = "bar" } }` | ||
| -- Expressions table: {"{ [\"foo\"] = \"bar\" }"} (trimmed, but internal formatting preserved) | ||
| ``` | ||
|
|
||
| For method calls (`:` syntax), `self` is passed first as usual, followed by these three arguments. | ||
|
|
||
| If the interpolated string contains no interpolation expressions, the call passes a single string argument (the literal string), identical to a regular string literal call. | ||
|
|
||
| ### Examples | ||
|
|
||
| ```luau | ||
| local id = 42 | ||
| local user = {name = "Alice"} | ||
|
|
||
| -- Simple identifier | ||
| log:Info `Processing item {id}` | ||
| -- Desugars to: log:Info("Processing item %*", {42}, {"id"}) | ||
|
|
||
| -- Member expression | ||
| log:Info `User {user.name} logged in` | ||
| -- Desugars to: log:Info("User %* logged in", {"Alice"}, {"user.name"}) | ||
|
|
||
| -- Multiple expressions | ||
| log:Info `{user.name} is processing item {id}` | ||
| -- Desugars to: log:Info("%* is processing item %*", {"Alice", 42}, {"user.name", "id"}) | ||
| ``` | ||
|
|
||
| ### No expression restrictions | ||
|
|
||
| Unlike some alternative designs that use named keys for interpolated values, this design uses positional tables. This means **any expression valid in a regular interpolated string is also valid here**, including function and method calls: | ||
|
|
||
| ```luau | ||
| -- Function calls are allowed | ||
| log:Info `Result is {compute()}` | ||
| -- Desugars to: log:Info("Result is %*", {compute()}, {"compute()"}) | ||
|
|
||
| -- Method calls are allowed | ||
| log:Info `Name is {user:getName()}` | ||
| -- Desugars to: log:Info("Name is %*", {user:getName()}, {"user:getName()"}) | ||
|
|
||
| -- Repeated expressions are fine (each is a separate positional entry) | ||
| log:Info `{increment()} and then {increment()}` | ||
| -- Desugars to: log:Info("%* and then %*", {increment(), increment()}, {"increment()", "increment()"}) | ||
| ``` | ||
|
|
||
| Since values are stored positionally rather than keyed by expression text, there is no ambiguity when the same expression appears multiple times or when expressions have side effects. | ||
|
|
||
| ### Behavior with variadic functions | ||
|
|
||
| Functions that accept variadic arguments will receive the three desugared arguments. For example, `print` concatenates its arguments with spaces: | ||
|
|
||
| ```luau | ||
| local name = "Alice" | ||
| print `Hello {name}` | ||
| -- Desugars to: print("Hello %*", {"Alice"}, {"name"}) | ||
| -- Output: Hello %* table: 0x... table: 0x... | ||
| ``` | ||
|
|
||
| This is likely not the desired output. Developers wanting simple string interpolation should use parentheses: | ||
|
hgoldstein marked this conversation as resolved.
Outdated
|
||
|
|
||
| ```luau | ||
| print(`Hello {name}`) -- Output: Hello Alice | ||
| ``` | ||
|
|
||
| The parentheses-free form is intended for functions specifically designed to receive the expanded arguments. | ||
|
|
||
| ### Interaction with existing syntax | ||
|
|
||
| This feature does not change the behavior of: | ||
|
|
||
| - Regular string literals: `print "hello"` continues to pass a single string argument | ||
| - Table literals: `print {1, 2, 3}` continues to pass a single table argument | ||
| - Parenthesized calls: `print(`hello {name}`)` continues to pass a single formatted string | ||
|
|
||
| The new behavior only applies to calls without parentheses using interpolated strings. | ||
|
|
||
| ### Passing additional context via currying | ||
|
|
||
| Some use cases (e.g. structured logging) benefit from passing additional context alongside the interpolated string. Rather than introducing special syntax for an extra argument, this can be achieved through currying (a function that returns a function): | ||
|
|
||
| ```luau | ||
| -- log accepts the desugared interpolated string arguments and returns | ||
| -- a function that accepts additional context | ||
| function log(fmt, vals, exprs) | ||
| return function(context) | ||
| -- Reconstruct the formatted message | ||
| local message = string.format(fmt, table.unpack(vals)) | ||
| -- Reconstruct a human-readable template using the expression names | ||
| local wrapped = table.create(#exprs) | ||
| for i, expr in exprs do | ||
| wrapped[i] = "{" .. expr .. "}" | ||
| end | ||
| local template = string.format(fmt, table.unpack(wrapped)) | ||
| -- template is now "Hello {name}" -- the original template string. | ||
| emit(message, template, vals, exprs, context) | ||
| end | ||
| end | ||
|
|
||
| -- No parentheses anywhere -- log is called with the desugared | ||
| -- interpolated string and returns a function, which is then called | ||
| -- with the context table | ||
| log `Hello {name}` {userId = 12345, region = "us-east"} | ||
|
|
||
| -- Desugars to: log("Hello %*", {"Alice"}, {"name"})({userId = 12345, region = "us-east"}) | ||
| ``` | ||
|
|
||
| This approach requires no special grammar support. It is a natural composition of a parentheses-free interpolated string call (which returns a function) and a parentheses-free table literal call on that returned function. | ||
|
|
||
| ### Bridging to existing functions with a `Message` type | ||
|
|
||
| A `Message` wrapper type can bridge the gap between this calling convention and existing functions like `print` or `warn` that expect string arguments: | ||
|
|
||
| ```luau | ||
| local Message = {} | ||
| Message.__index = Message | ||
|
|
||
| function Message.new(fmt, vals, exprs) | ||
| return setmetatable({ | ||
| fmt = fmt, | ||
| values = vals, | ||
| expressions = exprs, | ||
| }, Message) | ||
| end | ||
|
|
||
| function Message:__tostring() | ||
| return string.format(self.fmt, table.unpack(self.values)) | ||
| end | ||
| ``` | ||
|
|
||
| Because `Message` defines `__tostring`, existing functions that convert arguments to strings will produce the expected formatted output: | ||
|
|
||
| ```luau | ||
| -- Message.new receives the desugared arguments and returns a Message object | ||
| -- print() calls tostring on it, producing "Hello Alice" | ||
| print(Message.new `Hello {name}`) | ||
|
|
||
| -- warn works the same way | ||
| warn(Message.new `User {user.name} failed to log in`) | ||
| ``` | ||
|
|
||
| Structured logging APIs can also inspect the Message's fields directly: | ||
|
|
||
| ```luau | ||
| function structured_log(msg: Message) | ||
| emit(tostring(msg), msg.fmt, msg.values, msg.expressions) | ||
| end | ||
| ``` | ||
|
|
||
| While not part of this proposal, this pattern also opens the door for existing functions to be updated to special-case a single `Message` argument, extracting structured data for telemetry while preserving their current behavior for all other callers. | ||
|
hgoldstein marked this conversation as resolved.
Outdated
|
||
|
|
||
| ## Drawbacks | ||
|
|
||
| ### Increased complexity in the grammar | ||
|
|
||
| Adding interpolated strings as a parentheses-free call argument adds complexity to the parser. However, the grammar change is minimal and unambiguous. | ||
|
|
||
| ### Learning curve | ||
|
|
||
| Developers need to understand that interpolated string calls without parentheses behave differently from parenthesized calls. This could be confusing: | ||
|
|
||
| ```luau | ||
| log:Info `Hello {name}` -- passes 3 arguments (format, values, expressions) | ||
| log:Info(`Hello {name}`) -- passes 1 argument (formatted string) | ||
| ``` | ||
|
|
||
| This distinction is intentional and valuable for DSL use cases, but documentation will need to clearly explain the difference. | ||
|
|
||
| ### Surprising behavior with existing functions | ||
|
|
||
| Existing functions that are not designed for this calling convention will receive unexpected arguments if called without parentheses. For example, `print` and `warn` accept variadic arguments and would print the format string, values table, and expressions table alongside each other rather than producing a formatted message. | ||
|
|
||
| Functions designed for parentheses-free interpolated string calls would need to be written (or updated, if possible) to accept the three-argument format. When an existing function cannot be updated in a non-breaking way (for example, because it accepts variadic arguments), a pattern such as the `Message` wrapper type described in the Design section can be used instead to bridge the gap. | ||
|
|
||
| ## Alternatives | ||
|
|
||
| ### Tagged interpolated strings (JavaScript-style) | ||
|
|
||
| JavaScript's tagged template literals pass an array of string parts and the interpolated values as separate arguments: | ||
|
|
||
| ```javascript | ||
| tag`Hello ${name}, you have ${count} messages`; | ||
| // Calls: tag(["Hello ", ", you have ", " messages"], name, count) | ||
| ``` | ||
|
|
||
| A Luau adaptation would pass two tables, string parts and values: | ||
|
|
||
| ```luau | ||
| tag `Hello {name}, you have {count} messages` | ||
| -- Would call: tag({"Hello ", ", you have ", " messages"}, {name, count}) | ||
| ``` | ||
|
|
||
| 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. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This kind of works, but with two drawbacks:
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. |
||
| 2. Is more familiar to Luau developers accustomed to `string.format`-style patterns | ||
| 3. Includes expression metadata (source text) useful for debugging and structured logging | ||
|
|
||
| ### Interpolation table with named keys | ||
|
|
||
| An earlier version of this RFC proposed passing a table mapping expression names to their values: | ||
|
|
||
| ```luau | ||
| log:Info `Hello {name}` | ||
| -- Would pass: log:Info("Hello Alice", "Hello {name}", {name = "Alice"}) | ||
| ``` | ||
|
|
||
| This was rejected because: | ||
|
|
||
| 1. It requires consumers to parse the `{...}` syntax in the template string to reconstruct the formatted output | ||
| 2. Complex expressions like `{a + b}` or `{user.name}` create awkward or ambiguous table keys | ||
| 3. Function and method calls must be restricted because repeated calls (e.g. `{increment()}` appearing twice) can produce different values for the same key | ||
| 4. Metamethods on property access can cause the same issues as function calls, as noted by reviewers | ||
|
|
||
| The positional values table avoids all of these problems. | ||
|
|
||
| ### Extra context argument in grammar | ||
|
|
||
| An earlier version proposed allowing an optional trailing table literal after the interpolated string: | ||
|
|
||
| ```luau | ||
| log:Info `Hello {name}`, {userId = 12345} | ||
| ``` | ||
|
|
||
| This was rejected because it adds ambiguity to the grammar and is overly specific to the logging use case. The currying pattern described in the Design section provides equivalent functionality without grammar changes. | ||
|
|
||
| ## References | ||
|
|
||
| - [String interpolation RFC](https://github.com/luau-lang/rfcs/blob/master/docs/syntax-string-interpolation.md) - The original RFC that introduced interpolated strings and noted the temporary restriction | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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:
... 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.