From 6bad10ad5bb4bcc31452627e09183db71ec9e3f7 Mon Sep 17 00:00:00 2001 From: Richard Frankel Date: Thu, 29 Jan 2026 13:44:58 -0800 Subject: [PATCH 1/7] RFC: Interpolated string function calls 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 --- ...ntax-interpolated-string-function-calls.md | 306 ++++++++++++++++++ 1 file changed, 306 insertions(+) create mode 100644 docs/syntax-interpolated-string-function-calls.md diff --git a/docs/syntax-interpolated-string-function-calls.md b/docs/syntax-interpolated-string-function-calls.md new file mode 100644 index 000000000..cad18b885 --- /dev/null +++ b/docs/syntax-interpolated-string-function-calls.md @@ -0,0 +1,306 @@ +# Interpolated string function calls + +## Summary + +Allow calling functions with interpolated string literals without parentheses, optionally followed by a context table literal argument. This enables domain-specific language patterns like structured logging where the template, interpolation values, and optional additional context are all passed to the function. + +## 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 and extends it further to support an optional trailing table literal, enabling powerful DSL patterns. The primary motivating use case is structured logging, where it's valuable to capture both the formatted message and the template with its interpolation values for machine processing: + +```luau +local name = "Alice" +local log = require("@rbx/logging") + +-- Calls log:Info with three arguments: +-- "Hello Alice" (formatted string) +-- "Hello {name}" (template) +-- {name = "Alice"} (interpolation values) +log:Info `Hello {name}` + +-- Calls log:Info with four arguments, adding structured context: +-- "Hello Alice" (formatted string) +-- "Hello {name}" (template) +-- {name = "Alice"} (interpolation values) +-- {userId = 12345, region = "us-east"} (additional context) +log:Info `Hello {name}`, {userId = 12345, region = "us-east"} +``` + +This pattern is common in structured logging, where preserving the template enables aggregating logs by message pattern while the interpolation values and context provide searchable structured data for platforms like Splunk, Datadog, or Elasticsearch. + +Without this feature, there are two alternatives: + +1. Manually construct all arguments: + + ```luau + log:Info(`Hello {name}`, "Hello {name}", {name = name}, {userId = 12345, region = "us-east"}) + ``` + + This is tedious, duplicates the `name` variable twice (and the template string once), and is easy to get wrong when refactoring. + +2. Use a logging library that performs its own template parsing at runtime: + + ```luau + log:Info("Hello {name}", {name = name, userId = 12345, region = "us-east"}) + ``` + + This doesn't require new syntax, but requires the logging library to implement its own string interpolation separate from the language's built-in interpolation. It also cannot benefit from compile-time optimizations or static analysis that language-level support would enable. + +## Design + +### Grammar + +The grammar for function calls is extended to allow an interpolated string as the argument, optionally followed by a comma and a table literal: + +``` +functioncall ::= prefixexp args +args ::= '(' [explist] ')' | tableconstructor | LiteralString | stringinterp [',' tableconstructor] +``` + +Note that the optional `, tableconstructor` is only valid following `stringinterp`, not following a regular `LiteralString` or standalone `tableconstructor`. + +### Semantics + +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}"` +3. **Interpolation table**: A table mapping placeholder names to their values at call time, e.g. `{name = "Alice"}` +4. **Context table** (optional): If a table literal follows the interpolated string, it is passed as a fourth argument + +For method calls (`:` syntax), `self` is passed first as usual, followed by these arguments. + +### Examples + +```luau +local id = 42 +local user = {name = "Alice"} + +-- Basic usage: passes three arguments +log:Info `Processing item {id}` +-- Desugars to: log:Info("Processing item 42", "Processing item {id}", {id = 42}) + +-- With context table: passes four arguments +log:Info `User {user.name} logged in`, {timestamp = os.time()} +-- Desugars to: log:Info("User Alice logged in", "User {user.name} logged in", {user = {name = "Alice"}}, {timestamp = 1234567890}) +``` + +### Behavior with variadic functions + +Functions that accept variadic arguments will receive all the desugared arguments. For example, Roblox's `print` concatenates its arguments with spaces: + +```luau +local name = "Alice" +print `Hello {name}` +-- Desugars to: print("Hello Alice", "Hello {name}", {name = "Alice"}) +-- Output: Hello Alice Hello {name} table: 0x... +``` + +This is likely not the desired output. Developers wanting simple string interpolation should use parentheses: + +```luau +print(`Hello {name}`) -- Output: Hello Alice +``` + +The parentheses-free form is intended for functions specifically designed to receive the expanded arguments, such as structured logging APIs. + +### Handling of duplicate placeholder names + +If the same identifier appears multiple times in a template, it appears once in the interpolation table: + +```luau +log:Info `{x} + {x} = {result}` +-- Interpolation table is {x = 10, result = 20}, not {x = 10, x = 10, result = 20} +``` + +### Interpolation table keys for member expressions + +When a placeholder contains a member expression like `{user.name}`, the interpolation table uses nested tables mirroring the access path: + +```luau +log:Info `{user.name} is {user.age} years old` +-- Interpolation table is {user = {name = "Alice", age = 30}} +``` + +This approach is more ergonomic for consuming code, which can use natural table access like `context.user.name`, and is more idiomatic to Lua/Luau's table-centric design. + +When multiple member expressions share a common root, their values are merged into a single nested structure. If a simple identifier `{user}` and a member expression `{user.name}` both appear, the simple identifier provides the full object, which already contains the nested values. + +An alternative design would use flat string keys: + +```luau +-- Alternative (not proposed): {["user.name"] = "Alice", ["user.age"] = 30} +``` + +This would be simpler to implement (no merging logic required) but less ergonomic, requiring consuming code to use string keys like `context["user.name"]`. + +### 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. + +### Expression restrictions in templates + +When used in this calling style, the interpolated expressions within the template may not contain function or method calls: + +```luau +-- Valid: simple identifiers +log:Info `Hello {name}` + +-- Valid: member expressions +log:Info `User {user.name} logged in` + +-- Valid: arithmetic and other operators +log:Info `Sum is {a + b}` +-- Desugars to: log:Info("Sum is 30", "Sum is {a + b}", {a = 10, b = 20}) + +-- Valid: other expressions +log:Info `Count is {#items}` -- {items = {...}} +log:Info `Active: {not disabled}` -- {disabled = false} + +-- Invalid: function calls +log:Info `Result is {compute()}` -- parse error + +-- Invalid: method calls +log:Info `Name is {user:getName()}` -- parse error +``` + +Function and method calls are restricted because the same call expression can appear multiple times and return different values: + +```luau +log:Info `{increment()} and then {increment()}` +-- Formatted: "1 and then 2" +-- Template: "{increment()} and then {increment()}" +-- Context: ??? (can't use "increment()" as key twice with different values) +``` + +For all other expressions, the compiler extracts the referenced identifiers and includes them in the interpolation table. Identifiers have stable values within a single expression, so duplicates are not a problem. + +For function calls, use a local variable or the traditional parenthesized call syntax: + +```luau +local result = compute() +log:Info `Result is {result}` -- Valid: use a local variable + +-- Or use parentheses for full flexibility +log:Info("Result is " .. tostring(compute())) +``` + +#### Potential future extension + +A future version could allow function calls by using indexed keys to distinguish multiple calls to the same expression: + +```luau +log:Info `{increment()} and then {increment()}` +-- Could produce: {["increment()#1"] = 1, ["increment()#2"] = 2} +``` + +This would also capture any identifiers referenced in the call arguments: + +```luau +log:Info `{compute(x)} and {compute(x)}` +-- Could produce: {x = 10, ["compute(x)#1"] = 42, ["compute(x)#2"] = 43} +``` + +However, this approach has a complication: function calls can mutate values, affecting subsequent expressions in the template: + +```luau +log:Info `{x} {mutate(x)} {x}` +-- First {x} sees original value +-- mutate(x) changes x +-- Third {x} sees mutated value +-- But context only captures x once—which value? +``` + +This affects all identifiers, not just function call results. To handle this correctly, the indexed approach would need to apply to every expression in the template, not just function calls: + +```luau +log:Info `{x} {mutate(x)} {x}` +-- Could produce: { +-- ["x#1"] = {value = 1}, +-- ["mutate(x)#2"] = {result = nil, args = {x = {value = 1}}}, +-- ["x#3"] = {value = 2} +-- } +``` + +This adds significant complexity. An alternative would be to document that the context captures values at an unspecified point during evaluation, and that templates with side effects may produce inconsistent results. This is left for future consideration. + +## Drawbacks + +### Increased complexity in the grammar + +Adding optional trailing arguments after interpolated strings adds complexity to the parser. However, the grammar remains unambiguous since the comma can only appear in this context. + +### Use-case-specific syntax + +The optional trailing context table argument is motivated primarily by structured logging, where additional metadata beyond the interpolated values is useful. In a pure language context, this extra argument may seem overly specific to one use case. However, the context table is optional, and the core feature (passing template and interpolation values to functions) is broadly useful for DSL patterns beyond logging. + +### 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 +log:Info(`Hello {name}`) -- passes 1 argument +``` + +This distinction is intentional and valuable for DSL use cases, but documentation will need to clearly explain the difference. + +### Roblox built-in logging functions + +In the Roblox engine, `print`, `warn`, and `error` have non-standard behavior: calls to them are treated as logging calls and feed into log telemetry. These functions handle arguments differently: + +- `print`: Accepts any number of arguments and prints their values (without calling `tostring`, though `__tostring` metamethods fire for tables) +- `warn`: Accepts any number of arguments, converts them to strings, and joins them with spaces, outputting as a yellow warning with timestamp +- `error`: Expects a single message argument and terminates execution + +For `print` and `warn`, parentheses-free interpolated string calls would produce confusing output since the template string and interpolation table would be printed alongside the formatted message. For `error`, the first argument is still the formatted message, so it would function correctly; the extra arguments would simply be ignored. + +Roblox could update these functions to leverage the extra arguments for structured log telemetry. Alternatively, a new structured logging library could be designed from the start to accept parentheses-free interpolated string calls, while developers continue using parenthesized calls for the legacy functions. + +### Restriction on function calls + +The restriction on function and method calls may frustrate developers who want to inline computed values. It also introduces inconsistency: regular string interpolation allows `{compute()}`, but the parentheses-free calling form does not. However, this restriction avoids the complexity of handling duplicate call expressions that return different values (e.g., `{increment()} and {increment()}` returning 1 and 2). A potential future extension using indexed keys is described in the Design section. + +## 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) +``` + +This was considered but rejected because: + +1. It doesn't provide the template string directly, requiring reconstruction +2. It doesn't provide a table mapping names to values +3. The array-based API is less natural for Luau's table-centric design + +## 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 +- [API-1080](https://roblox.atlassian.net/browse/API-1080) - Structured logging APIs proposal that motivated this RFC From 2458396e2f817696ac175fa5c1cdfd32dcdc11f5 Mon Sep 17 00:00:00 2001 From: Richard Frankel Date: Mon, 9 Feb 2026 21:37:24 -0800 Subject: [PATCH 2/7] Revise RFC based on PR feedback 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 --- ...ntax-interpolated-string-function-calls.md | 340 +++++++++--------- 1 file changed, 176 insertions(+), 164 deletions(-) diff --git a/docs/syntax-interpolated-string-function-calls.md b/docs/syntax-interpolated-string-function-calls.md index cad18b885..825d7b052 100644 --- a/docs/syntax-interpolated-string-function-calls.md +++ b/docs/syntax-interpolated-string-function-calls.md @@ -2,7 +2,7 @@ ## Summary -Allow calling functions with interpolated string literals without parentheses, optionally followed by a context table literal argument. This enables domain-specific language patterns like structured logging where the template, interpolation values, and optional additional context are all passed to the function. +Allow calling functions with interpolated string literals without parentheses. The call is desugared into a function call with three arguments: a format string with `%s` 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 @@ -22,69 +22,79 @@ 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 and extends it further to support an optional trailing table literal, enabling powerful DSL patterns. The primary motivating use case is structured logging, where it's valuable to capture both the formatted message and the template with its interpolation values for machine processing: +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. -```luau -local name = "Alice" -local log = require("@rbx/logging") +### Structured logging --- Calls log:Info with three arguments: --- "Hello Alice" (formatted string) --- "Hello {name}" (template) --- {name = "Alice"} (interpolation values) -log:Info `Hello {name}` +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: --- Calls log:Info with four arguments, adding structured context: --- "Hello Alice" (formatted string) --- "Hello {name}" (template) --- {name = "Alice"} (interpolation values) --- {userId = 12345, region = "us-east"} (additional context) -log:Info `Hello {name}`, {userId = 12345, region = "us-east"} +```luau +local a = 1 +local function double(x: number) + return x * 2 +end + +-- Desugars to `log:Info("The double of %s is %s", {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)}` ``` -This pattern is common in structured logging, where preserving the template enables aggregating logs by message pattern while the interpolation values and context provide searchable structured data for platforms like Splunk, Datadog, or Elasticsearch. +The template string enables aggregating logs by message pattern (e.g. grouping all "The double of %s is %s" messages), while the values and expression names provide searchable structured data for platforms like Splunk, Datadog, or Elasticsearch. -Without this feature, there are two alternatives: +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). -1. Manually construct all arguments: +### SQL escaping - ```luau - log:Info(`Hello {name}`, "Hello {name}", {name = name}, {userId = 12345, region = "us-east"}) - ``` +Interpolated string calls enable safe, ergonomic SQL query construction where the function can automatically escape interpolated values: - This is tedious, duplicates the `name` variable twice (and the template string once), and is easy to get wrong when refactoring. +```luau +local sqlite = require("luau_sqlite") -2. Use a logging library that performs its own template parsing at runtime: +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 (%s, %s)", {user, comment}, {"user", "comment"}) +end +``` - ```luau - log:Info("Hello {name}", {name = name, userId = 12345, region = "us-east"}) - ``` +### HTML templating - This doesn't require new syntax, but requires the logging library to implement its own string interpolation separate from the language's built-in interpolation. It also cannot benefit from compile-time optimizations or static analysis that language-level support would enable. +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 %s", {userinput}, {"userinput"}) +end +``` ## Design ### Grammar -The grammar for function calls is extended to allow an interpolated string as the argument, optionally followed by a comma and a table literal: +The grammar for function calls is extended to allow an interpolated string as the argument: ``` functioncall ::= prefixexp args -args ::= '(' [explist] ')' | tableconstructor | LiteralString | stringinterp [',' tableconstructor] +args ::= '(' [explist] ')' | tableconstructor | LiteralString | stringinterp ``` -Note that the optional `, tableconstructor` is only valid following `stringinterp`, not following a regular `LiteralString` or standalone `tableconstructor`. - ### Semantics -When a function is called with an interpolated string literal in this style, the call receives multiple arguments derived from the interpolated string: +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. **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}"` -3. **Interpolation table**: A table mapping placeholder names to their values at call time, e.g. `{name = "Alice"}` -4. **Context table** (optional): If a table literal follows the interpolated string, it is passed as a fourth argument +1. **Format string**: The template with each `{expression}` replaced by `%s`, e.g. `"The double of %s is %s"` +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)"}` -For method calls (`:` syntax), `self` is passed first as usual, followed by these arguments. +The format string and expressions table are determined at compile time. The values table is evaluated at runtime. + +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 @@ -92,63 +102,57 @@ For method calls (`:` syntax), `self` is passed first as usual, followed by thes local id = 42 local user = {name = "Alice"} --- Basic usage: passes three arguments +-- Simple identifier log:Info `Processing item {id}` --- Desugars to: log:Info("Processing item 42", "Processing item {id}", {id = 42}) +-- Desugars to: log:Info("Processing item %s", {42}, {"id"}) --- With context table: passes four arguments -log:Info `User {user.name} logged in`, {timestamp = os.time()} --- Desugars to: log:Info("User Alice logged in", "User {user.name} logged in", {user = {name = "Alice"}}, {timestamp = 1234567890}) +-- Member expression +log:Info `User {user.name} logged in` +-- Desugars to: log:Info("User %s logged in", {"Alice"}, {"user.name"}) + +-- Multiple expressions +log:Info `{user.name} is processing item {id}` +-- Desugars to: log:Info("%s is processing item %s", {"Alice", 42}, {"user.name", "id"}) ``` -### Behavior with variadic functions +### No expression restrictions -Functions that accept variadic arguments will receive all the desugared arguments. For example, Roblox's `print` concatenates its arguments with spaces: +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 -local name = "Alice" -print `Hello {name}` --- Desugars to: print("Hello Alice", "Hello {name}", {name = "Alice"}) --- Output: Hello Alice Hello {name} table: 0x... -``` +-- Function calls are allowed +log:Info `Result is {compute()}` +-- Desugars to: log:Info("Result is %s", {compute()}, {"compute()"}) -This is likely not the desired output. Developers wanting simple string interpolation should use parentheses: +-- Method calls are allowed +log:Info `Name is {user:getName()}` +-- Desugars to: log:Info("Name is %s", {user:getName()}, {"user:getName()"}) -```luau -print(`Hello {name}`) -- Output: Hello Alice +-- Repeated expressions are fine (each is a separate positional entry) +log:Info `{increment()} and then {increment()}` +-- Desugars to: log:Info("%s and then %s", {increment(), increment()}, {"increment()", "increment()"}) ``` -The parentheses-free form is intended for functions specifically designed to receive the expanded arguments, such as structured logging APIs. - -### Handling of duplicate placeholder names - -If the same identifier appears multiple times in a template, it appears once in the interpolation table: - -```luau -log:Info `{x} + {x} = {result}` --- Interpolation table is {x = 10, result = 20}, not {x = 10, x = 10, result = 20} -``` +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. -### Interpolation table keys for member expressions +### Behavior with variadic functions -When a placeholder contains a member expression like `{user.name}`, the interpolation table uses nested tables mirroring the access path: +Functions that accept variadic arguments will receive the three desugared arguments. For example, Roblox's `print` concatenates its arguments with spaces: ```luau -log:Info `{user.name} is {user.age} years old` --- Interpolation table is {user = {name = "Alice", age = 30}} +local name = "Alice" +print `Hello {name}` +-- Desugars to: print("Hello %s", {"Alice"}, {"name"}) +-- Output: Hello %s table: 0x... table: 0x... ``` -This approach is more ergonomic for consuming code, which can use natural table access like `context.user.name`, and is more idiomatic to Lua/Luau's table-centric design. - -When multiple member expressions share a common root, their values are merged into a single nested structure. If a simple identifier `{user}` and a member expression `{user.name}` both appear, the simple identifier provides the full object, which already contains the nested values. - -An alternative design would use flat string keys: +This is likely not the desired output. Developers wanting simple string interpolation should use parentheses: ```luau --- Alternative (not proposed): {["user.name"] = "Alice", ["user.age"] = 30} +print(`Hello {name}`) -- Output: Hello Alice ``` -This would be simpler to implement (no merging logic required) but less ergonomic, requiring consuming code to use string keys like `context["user.name"]`. +The parentheses-free form is intended for functions specifically designed to receive the expanded arguments. ### Interaction with existing syntax @@ -160,128 +164,102 @@ This feature does not change the behavior of: The new behavior only applies to calls without parentheses using interpolated strings. -### Expression restrictions in templates +### Passing additional context via currying -When used in this calling style, the interpolated expressions within the template may not contain function or method calls: +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 --- Valid: simple identifiers -log:Info `Hello {name}` - --- Valid: member expressions -log:Info `User {user.name} logged in` - --- Valid: arithmetic and other operators -log:Info `Sum is {a + b}` --- Desugars to: log:Info("Sum is 30", "Sum is {a + b}", {a = 10, b = 20}) - --- Valid: other expressions -log:Info `Count is {#items}` -- {items = {...}} -log:Info `Active: {not disabled}` -- {disabled = false} - --- Invalid: function calls -log:Info `Result is {compute()}` -- parse error - --- Invalid: method calls -log:Info `Name is {user:getName()}` -- parse error -``` - -Function and method calls are restricted because the same call expression can appear multiple times and return different values: - -```luau -log:Info `{increment()} and then {increment()}` --- Formatted: "1 and then 2" --- Template: "{increment()} and then {increment()}" --- Context: ??? (can't use "increment()" as key twice with different values) +-- 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 %s", {"Alice"}, {"name"})({userId = 12345, region = "us-east"}) ``` -For all other expressions, the compiler extracts the referenced identifiers and includes them in the interpolation table. Identifiers have stable values within a single expression, so duplicates are not a problem. - -For function calls, use a local variable or the traditional parenthesized call syntax: - -```luau -local result = compute() -log:Info `Result is {result}` -- Valid: use a local variable - --- Or use parentheses for full flexibility -log:Info("Result is " .. tostring(compute())) -``` +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. -#### Potential future extension +### Bridging to existing functions with a `Message` type -A future version could allow function calls by using indexed keys to distinguish multiple calls to the same expression: +A `Message` wrapper type can bridge the gap between this calling convention and existing functions like `print` or `warn` that expect string arguments: ```luau -log:Info `{increment()} and then {increment()}` --- Could produce: {["increment()#1"] = 1, ["increment()#2"] = 2} +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 ``` -This would also capture any identifiers referenced in the call arguments: +Because `Message` defines `__tostring`, existing functions that convert arguments to strings will produce the expected formatted output: ```luau -log:Info `{compute(x)} and {compute(x)}` --- Could produce: {x = 10, ["compute(x)#1"] = 42, ["compute(x)#2"] = 43} -``` +-- Message.new receives the desugared arguments and returns a Message object +-- print() calls tostring on it, producing "Hello Alice" +print(Message.new `Hello {name}`) -However, this approach has a complication: function calls can mutate values, affecting subsequent expressions in the template: - -```luau -log:Info `{x} {mutate(x)} {x}` --- First {x} sees original value --- mutate(x) changes x --- Third {x} sees mutated value --- But context only captures x once—which value? +-- warn works the same way +warn(Message.new `User {user.name} failed to log in`) ``` -This affects all identifiers, not just function call results. To handle this correctly, the indexed approach would need to apply to every expression in the template, not just function calls: +Structured logging APIs can also inspect the Message's fields directly: ```luau -log:Info `{x} {mutate(x)} {x}` --- Could produce: { --- ["x#1"] = {value = 1}, --- ["mutate(x)#2"] = {result = nil, args = {x = {value = 1}}}, --- ["x#3"] = {value = 2} --- } +function structured_log(msg: Message) + emit(tostring(msg), msg.fmt, msg.values, msg.expressions) +end ``` -This adds significant complexity. An alternative would be to document that the context captures values at an unspecified point during evaluation, and that templates with side effects may produce inconsistent results. This is left for future consideration. +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. ## Drawbacks ### Increased complexity in the grammar -Adding optional trailing arguments after interpolated strings adds complexity to the parser. However, the grammar remains unambiguous since the comma can only appear in this context. - -### Use-case-specific syntax - -The optional trailing context table argument is motivated primarily by structured logging, where additional metadata beyond the interpolated values is useful. In a pure language context, this extra argument may seem overly specific to one use case. However, the context table is optional, and the core feature (passing template and interpolation values to functions) is broadly useful for DSL patterns beyond logging. +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 -log:Info(`Hello {name}`) -- passes 1 argument +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. -### Roblox built-in logging functions - -In the Roblox engine, `print`, `warn`, and `error` have non-standard behavior: calls to them are treated as logging calls and feed into log telemetry. These functions handle arguments differently: - -- `print`: Accepts any number of arguments and prints their values (without calling `tostring`, though `__tostring` metamethods fire for tables) -- `warn`: Accepts any number of arguments, converts them to strings, and joins them with spaces, outputting as a yellow warning with timestamp -- `error`: Expects a single message argument and terminates execution - -For `print` and `warn`, parentheses-free interpolated string calls would produce confusing output since the template string and interpolation table would be printed alongside the formatted message. For `error`, the first argument is still the formatted message, so it would function correctly; the extra arguments would simply be ignored. +### Surprising behavior with existing functions -Roblox could update these functions to leverage the extra arguments for structured log telemetry. Alternatively, a new structured logging library could be designed from the start to accept parentheses-free interpolated string calls, while developers continue using parenthesized calls for the legacy functions. +Existing functions that are not designed for this calling convention will receive unexpected arguments if called without parentheses. For example, in Roblox, `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. -### Restriction on function calls - -The restriction on function and method calls may frustrate developers who want to inline computed values. It also introduces inconsistency: regular string interpolation allows `{compute()}`, but the parentheses-free calling form does not. However, this restriction avoids the complexity of handling duplicate call expressions that return different values (e.g., `{increment()} and {increment()}` returning 1 and 2). A potential future extension using indexed keys is described in the Design section. +Functions designed for parentheses-free interpolated string calls would need to be written (or updated) to accept the three-argument format. Developers should continue using parenthesized calls for existing functions, or use a pattern such as the `Message` wrapper type described in the Design section to bridge the gap. ## Alternatives @@ -294,13 +272,47 @@ tag`Hello ${name}, you have ${count} messages`; // Calls: tag(["Hello ", ", you have ", " messages"], name, count) ``` -This was considered but rejected because: +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 `%s` 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 +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} +``` -1. It doesn't provide the template string directly, requiring reconstruction -2. It doesn't provide a table mapping names to values -3. The array-based API is less natural for Luau's table-centric design +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 -- [API-1080](https://roblox.atlassian.net/browse/API-1080) - Structured logging APIs proposal that motivated this RFC From 0aa415c4c31c0709f323a5486080f246ce2b4eaa Mon Sep 17 00:00:00 2001 From: Richard Frankel Date: Tue, 10 Feb 2026 09:51:37 -0800 Subject: [PATCH 3/7] Use %* format specifier instead of %s; clarify existing function drawback Co-authored-by: Cursor --- ...ntax-interpolated-string-function-calls.md | 34 +++++++++---------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/docs/syntax-interpolated-string-function-calls.md b/docs/syntax-interpolated-string-function-calls.md index 825d7b052..79a4e62f2 100644 --- a/docs/syntax-interpolated-string-function-calls.md +++ b/docs/syntax-interpolated-string-function-calls.md @@ -2,7 +2,7 @@ ## 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 `%s` 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. +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 @@ -34,12 +34,12 @@ local function double(x: number) return x * 2 end --- Desugars to `log:Info("The double of %s is %s", {a, double(a)}, {"a", "double(a)"})` +-- 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 %s is %s" messages), while the values and expression names provide searchable structured data for platforms like Splunk, Datadog, or Elasticsearch. +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). @@ -53,7 +53,7 @@ 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 (%s, %s)", {user, comment}, {"user", "comment"}) + -- Desugars to: db:Exec("INSERT INTO user_inputs (user, comment) VALUES (%*, %*)", {user, comment}, {"user", "comment"}) end ``` @@ -67,7 +67,7 @@ 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 %s", {userinput}, {"userinput"}) + -- Desugars to: tmpl.HTML("The user asked about %*", {userinput}, {"userinput"}) end ``` @@ -86,7 +86,7 @@ args ::= '(' [explist] ')' | tableconstructor | LiteralString | stringinterp 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 `%s`, e.g. `"The double of %s is %s"` +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)"}` @@ -104,15 +104,15 @@ local user = {name = "Alice"} -- Simple identifier log:Info `Processing item {id}` --- Desugars to: log:Info("Processing item %s", {42}, {"id"}) +-- Desugars to: log:Info("Processing item %*", {42}, {"id"}) -- Member expression log:Info `User {user.name} logged in` --- Desugars to: log:Info("User %s logged in", {"Alice"}, {"user.name"}) +-- Desugars to: log:Info("User %* logged in", {"Alice"}, {"user.name"}) -- Multiple expressions log:Info `{user.name} is processing item {id}` --- Desugars to: log:Info("%s is processing item %s", {"Alice", 42}, {"user.name", "id"}) +-- Desugars to: log:Info("%* is processing item %*", {"Alice", 42}, {"user.name", "id"}) ``` ### No expression restrictions @@ -122,15 +122,15 @@ Unlike some alternative designs that use named keys for interpolated values, thi ```luau -- Function calls are allowed log:Info `Result is {compute()}` --- Desugars to: log:Info("Result is %s", {compute()}, {"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 %s", {user:getName()}, {"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("%s and then %s", {increment(), increment()}, {"increment()", "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. @@ -142,8 +142,8 @@ Functions that accept variadic arguments will receive the three desugared argume ```luau local name = "Alice" print `Hello {name}` --- Desugars to: print("Hello %s", {"Alice"}, {"name"}) --- Output: Hello %s table: 0x... table: 0x... +-- 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: @@ -191,7 +191,7 @@ end -- with the context table log `Hello {name}` {userId = 12345, region = "us-east"} --- Desugars to: log("Hello %s", {"Alice"}, {"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. @@ -259,7 +259,7 @@ This distinction is intentional and valuable for DSL use cases, but documentatio Existing functions that are not designed for this calling convention will receive unexpected arguments if called without parentheses. For example, in Roblox, `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) to accept the three-argument format. Developers should continue using parenthesized calls for existing functions, or use a pattern such as the `Message` wrapper type described in the Design section to bridge the gap. +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 @@ -279,7 +279,7 @@ 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 `%s` 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: +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 2. Is more familiar to Luau developers accustomed to `string.format`-style patterns From 1e3fce887d043688287e73f4172a49a894770c8c Mon Sep 17 00:00:00 2001 From: Richard Frankel Date: Wed, 11 Feb 2026 15:03:51 -0800 Subject: [PATCH 4/7] Clarify expression source text handling in expressions table 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 --- docs/syntax-interpolated-string-function-calls.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/syntax-interpolated-string-function-calls.md b/docs/syntax-interpolated-string-function-calls.md index 79a4e62f2..65be9f78b 100644 --- a/docs/syntax-interpolated-string-function-calls.md +++ b/docs/syntax-interpolated-string-function-calls.md @@ -92,6 +92,19 @@ When a function is called with an interpolated string literal in this style, the 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. From 38201338552d170871145721c0c90ae22bd92cae Mon Sep 17 00:00:00 2001 From: Richard Frankel Date: Wed, 11 Feb 2026 15:24:52 -0800 Subject: [PATCH 5/7] Remove Roblox-specific references Co-authored-by: Cursor --- docs/syntax-interpolated-string-function-calls.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/syntax-interpolated-string-function-calls.md b/docs/syntax-interpolated-string-function-calls.md index 65be9f78b..62cf33b17 100644 --- a/docs/syntax-interpolated-string-function-calls.md +++ b/docs/syntax-interpolated-string-function-calls.md @@ -150,7 +150,7 @@ Since values are stored positionally rather than keyed by expression text, there ### Behavior with variadic functions -Functions that accept variadic arguments will receive the three desugared arguments. For example, Roblox's `print` concatenates its arguments with spaces: +Functions that accept variadic arguments will receive the three desugared arguments. For example, `print` concatenates its arguments with spaces: ```luau local name = "Alice" @@ -270,7 +270,7 @@ This distinction is intentional and valuable for DSL use cases, but documentatio ### 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, in Roblox, `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. +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. From 629f68873421dc4a5b54ec53b0d91e371e65e94f Mon Sep 17 00:00:00 2001 From: Richard Frankel Date: Mon, 23 Feb 2026 14:30:08 -0800 Subject: [PATCH 6/7] Switch to byte-offset approach; strengthen motivation for template strings - Replace expressions list with byte-offset/length pairs per andyfriesen's suggestion - Template string now passed with {expressions} intact (serves as aggregation key) - Add product requirements for log consumption: human-readable templates and key-value structured data - Move expressions-list approach to Alternatives - Add manual format string + curried values to Alternatives per reviewer request Co-authored-by: Cursor --- ...ntax-interpolated-string-function-calls.md | 139 +++++++++++------- 1 file changed, 87 insertions(+), 52 deletions(-) diff --git a/docs/syntax-interpolated-string-function-calls.md b/docs/syntax-interpolated-string-function-calls.md index 62cf33b17..ca9a2f668 100644 --- a/docs/syntax-interpolated-string-function-calls.md +++ b/docs/syntax-interpolated-string-function-calls.md @@ -2,7 +2,7 @@ ## 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. +Allow calling functions with interpolated string literals without parentheses. The call is desugared into a function call with three arguments: the original template string (with interpolation expressions intact), a table of the evaluated values, and a table of byte-offset/length pairs locating each interpolation expression within the template. This enables domain-specific language patterns like structured logging, SQL escaping, and HTML templating. ## Motivation @@ -26,7 +26,13 @@ This proposal lifts that restriction with semantics that decompose the interpola ### 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: +The primary motivating use case is structured logging. Production log consumption systems have two key requirements: + +1. **Human-readable template strings** for aggregation. Logs need to be grouped by message pattern so that operators can see, for example, "200 instances/minute of '{foo} occurred after {bar}'" rather than an opaque format string or fingerprint. + +2. **Key-value structured data** for search and filtering. Given a template like `{user.name} logged in from {ip}`, the consuming system needs both the expression names (`user.name`, `ip`) and their runtime values (`"Alice"`, `"10.0.0.1"`) to enable queries like "show me all logs where user.name = 'Alice'". + +This proposal satisfies both requirements. The template string is passed directly as the first argument, and the byte offsets allow consumers to extract expression names and associate them with values: ```luau local a = 1 @@ -34,12 +40,11 @@ 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. +-- Desugars to: log:Info("The double of {a} is {double(a)}", {a, double(a)}, {{15, 3}, {22, 11}}) 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. +The template `"The double of {a} is {double(a)}"` serves directly as an aggregation key, and a consumer can extract the expression names `"a"` and `"double(a)"` via the byte offsets. 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). @@ -53,7 +58,7 @@ 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"}) + -- Desugars to: db:Exec("INSERT INTO user_inputs (user, comment) VALUES ({user}, {comment})", {user, comment}, {{47, 6}, {55, 9}}) end ``` @@ -67,7 +72,7 @@ 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"}) + -- Desugars to: tmpl.HTML("The user asked about {userinput}", {userinput}, {{22, 11}}) end ``` @@ -86,23 +91,19 @@ args ::= '(' [explist] ')' | tableconstructor | LiteralString | stringinterp 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)"}` +1. **Template string**: The original template with interpolation expressions intact, e.g. `"The double of {a} is {double(a)}"` +2. **Values table**: A sequential table of the evaluated interpolation values, e.g. `{a, double(a)}` +3. **Offsets table**: A sequential table of `{offset, length}` pairs, where each pair gives the 1-based byte offset and length of the corresponding `{expression}` span (including braces) within the template string -The format string and expressions table are determined at compile time. The values table is evaluated at runtime. +The template string and offsets 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: +A consumer can extract the expression text for the i-th interpolation using the offsets: ```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) +-- Extract {expr} including braces +local span = string.sub(template, offsets[i][1], offsets[i][1] + offsets[i][2] - 1) +-- Extract just the expression name (strip braces) +local expr = string.sub(template, offsets[i][1] + 1, offsets[i][1] + offsets[i][2] - 2) ``` For method calls (`:` syntax), `self` is passed first as usual, followed by these three arguments. @@ -117,33 +118,33 @@ local user = {name = "Alice"} -- Simple identifier log:Info `Processing item {id}` --- Desugars to: log:Info("Processing item %*", {42}, {"id"}) +-- Desugars to: log:Info("Processing item {id}", {42}, {{17, 4}}) -- Member expression log:Info `User {user.name} logged in` --- Desugars to: log:Info("User %* logged in", {"Alice"}, {"user.name"}) +-- Desugars to: log:Info("User {user.name} logged in", {"Alice"}, {{6, 11}}) -- Multiple expressions log:Info `{user.name} is processing item {id}` --- Desugars to: log:Info("%* is processing item %*", {"Alice", 42}, {"user.name", "id"}) +-- Desugars to: log:Info("{user.name} is processing item {id}", {"Alice", 42}, {{1, 11}, {33, 4}}) ``` ### 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: +This design uses positional tables, so **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()"}) +-- Desugars to: log:Info("Result is {compute()}", {compute()}, {{11, 11}}) -- Method calls are allowed log:Info `Name is {user:getName()}` --- Desugars to: log:Info("Name is %*", {user:getName()}, {"user:getName()"}) +-- Desugars to: log:Info("Name is {user:getName()}", {user:getName()}, {{9, 17}}) -- 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()"}) +-- Desugars to: log:Info("{increment()} and then {increment()}", {increment(), increment()}, {{1, 13}, {25, 13}}) ``` 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. @@ -155,8 +156,8 @@ Functions that accept variadic arguments will receive the three desugared argume ```luau local name = "Alice" print `Hello {name}` --- Desugars to: print("Hello %*", {"Alice"}, {"name"}) --- Output: Hello %* table: 0x... table: 0x... +-- Desugars to: print("Hello {name}", {"Alice"}, {{7, 6}}) +-- Output: Hello {name} table: 0x... table: 0x... ``` This is likely not the desired output. Developers wanting simple string interpolation should use parentheses: @@ -184,18 +185,16 @@ Some use cases (e.g. structured logging) benefit from passing additional context ```luau -- log accepts the desugared interpolated string arguments and returns -- a function that accepts additional context -function log(fmt, vals, exprs) +function log(template, vals, offsets) 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 .. "}" + -- The template (e.g. "Hello {name}") is already a human-readable aggregation key. + -- Build the formatted message by replacing {expr} spans with values. + local message = template + for i = #offsets, 1, -1 do + local pos, len = offsets[i][1], offsets[i][2] + message = string.sub(message, 1, pos - 1) .. tostring(vals[i]) .. string.sub(message, pos + len) end - local template = string.format(fmt, table.unpack(wrapped)) - -- template is now "Hello {name}" -- the original template string. - emit(message, template, vals, exprs, context) + emit(message, template, vals, context) end end @@ -204,7 +203,7 @@ end -- with the context table log `Hello {name}` {userId = 12345, region = "us-east"} --- Desugars to: log("Hello %*", {"Alice"}, {"name"})({userId = 12345, region = "us-east"}) +-- Desugars to: log("Hello {name}", {"Alice"}, {{7, 6}})({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. @@ -217,16 +216,21 @@ A `Message` wrapper type can bridge the gap between this calling convention and local Message = {} Message.__index = Message -function Message.new(fmt, vals, exprs) +function Message.new(template, vals, offsets) return setmetatable({ - fmt = fmt, + template = template, values = vals, - expressions = exprs, + offsets = offsets, }, Message) end function Message:__tostring() - return string.format(self.fmt, table.unpack(self.values)) + local result = self.template + for i = #self.offsets, 1, -1 do + local pos, len = self.offsets[i][1], self.offsets[i][2] + result = string.sub(result, 1, pos - 1) .. tostring(self.values[i]) .. string.sub(result, pos + len) + end + return result end ``` @@ -245,7 +249,7 @@ 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) + emit(tostring(msg), msg.template, msg.values, msg.offsets) end ``` @@ -262,7 +266,7 @@ Adding interpolated strings as a parentheses-free call argument adds complexity 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 3 arguments (template, values, offsets) log:Info(`Hello {name}`) -- passes 1 argument (formatted string) ``` @@ -270,12 +274,47 @@ This distinction is intentional and valuable for DSL use cases, but documentatio ### 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. +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 template string, values table, and offsets 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 +### Manual format string with curried values + +As noted by reviewers, a similar pattern is already achievable without new syntax using currying with a format string and a values table: + +```luau +local function Log(fmt: string) + return function(args: { any }) + local message = string.format(fmt, table.unpack(args)) + print("Log:", message, "Format:", fmt) + end +end + +local user = "Bottersnike" +Log "Hello, %*" { user } +``` + +While this works for simple cases, it has significant limitations: + +1. The developer must manually write the format string separately from the values, losing the ergonomic benefits of interpolated string syntax and introducing a risk of the two getting out of sync +2. The format string uses `%*` placeholders rather than the original expression names, so it cannot serve as a human-readable aggregation key (e.g., `"Hello, %*"` vs. `"Hello, {user}"`) +3. There is no way to recover expression names for structured key-value logging + +This RFC automates the decomposition that developers would otherwise have to do by hand, while preserving the original template and expression metadata. + +### Expressions list instead of byte offsets + +Instead of byte offsets, the third argument could be a sequential table of expression source texts: + +```luau +log:Info `Hello {name}` +-- Would pass: log:Info("Hello %*", {"Alice"}, {"name"}) +``` + +This is slightly more convenient for consumers who only need expression names, but requires the language specification to define normalization rules for source text (whitespace trimming, quote handling for literals, etc.). The byte-offset approach avoids this complexity by letting consumers extract whatever they need directly from the original template string. + ### Tagged interpolated strings (JavaScript-style) JavaScript's tagged template literals pass an array of string parts and the interpolated values as separate arguments: @@ -292,11 +331,7 @@ 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 -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 +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 preserving the original template string (useful as an aggregation key) and providing byte offsets for flexible extraction of expression metadata. ### Interpolation table with named keys From 7ba79c3a26088e79753adf44899c890180923878 Mon Sep 17 00:00:00 2001 From: Richard Frankel Date: Mon, 23 Feb 2026 14:57:59 -0800 Subject: [PATCH 7/7] Add concrete examples showing template string advantages over %* format strings - Ambiguity: two natural log patterns that produce identical %* format strings - Readability: complex pattern uninterpretable without source code lookup - Note that call stack fingerprints can disambiguate but remain opaque Co-authored-by: Cursor --- docs/syntax-interpolated-string-function-calls.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/syntax-interpolated-string-function-calls.md b/docs/syntax-interpolated-string-function-calls.md index ca9a2f668..6e304f0f6 100644 --- a/docs/syntax-interpolated-string-function-calls.md +++ b/docs/syntax-interpolated-string-function-calls.md @@ -46,6 +46,17 @@ log:Info `The double of {a} is {double(a)}` The template `"The double of {a} is {double(a)}"` serves directly as an aggregation key, and a consumer can extract the expression names `"a"` and `"double(a)"` via the byte offsets. +These template strings are substantially more useful than format strings with opaque `%*` placeholders. Compare these two log call sites: + +```luau +log `{query} returned {result} in {duration}ms` +log `{endpoint} returned {status} in {duration}ms` +``` + +With template strings, these aggregate separately, so operators can distinguish database queries from HTTP requests. With `%*` format strings, both produce `"%* returned %* in %*ms"` and would merge into a single bucket. Call stack metadata (e.g. fingerprints) could be used to disambiguate, but the resulting aggregation keys are opaque and require a source code lookup to interpret. + +Template strings also make aggregated log patterns self-documenting. An operator seeing a pattern like `"%*: %* %*:%* -> %*:%* proto=%* bytes=%*"` in a dashboard cannot map the first four `%*` to meaning without a source code lookup. The template `"{rule_id}: {action} {src_ip}:{src_port} -> {dst_ip}:{dst_port} proto={proto} bytes={bytes}"` is immediately interpretable. + 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