lloyAlloy on GitHub

← Every RFC

Luau parity and lighter headers

SyntaxImplementedThe file on GitHub →

Summary

A Luau file reads in Alloy the way Luau reads it, and five Alloy forms now mean what they look like. The words trait, impl, remote, macro, attribute, namespace, private, and public are names away from their declarations. [[ ... ]] is a long string. A declaration header needs no as when its body starts on the next line. A match guard is written where. A catch-all arm counts toward an exhaustive match, a value name in a pattern reports, and in on a { } literal with items reports. An arm can run statements before its value, and return in it leaves the function. @allow also takes the rustc and Clippy names of the lints Alloy has.

Motivation

Alloy promises that a Luau file compiles. Two rules broke that promise for ordinary Roblox code:

local remote = ReplicatedStorage.Remotes.Hit
local help = [[
Press E to open the shop.
]]

The first line reported "remote is a reserved word". The second did not lex, because [[ opened a nested array. A networking module names a local remote in its first ten lines, and a UI module holds text in long strings, so these two rules turned away the files a Luau user tries first.

A header wrote a word that said nothing:

struct Player as
    name: string
end

The line break already ends the header. Luau's own block headers take no such word, and the Luau class proposal writes class Point with the members below. Authors write the short form anyway: one game in the test bed wrote all nine of its impl and trait headers with no as, and each one was an error.

Four forms compiled to something the author did not mean, and three of them reported nothing:

const MAX = 10

match x with
    case MAX then "max"      -- binds a new MAX; the arm takes every value
    default "other"          -- never runs
end

print(2 in { 5, 6 })         -- true: 2 is a key of the table

match x with
    case 1 then "one"
    case _ then "other"      -- reported "not exhaustive"
end

match x with
    case n and n > 5 then    -- reads as one condition, `n and (n > 5)`
end

Design

The declaration words are names

Each word is a keyword where a declaration can start, and a name everywhere else. The rule is the one struct, enum, and interface already follow:

worda keyword before
trait, impl, macro, attribute, namespacea name on the same line
remotea name or function on the same line
private, publica name or function on the same line
local remote = folder.Hit           -- a name
remote Hit(n: number) from client   -- a declaration
local private = {}                  -- a name
struct P
    private hp: number              -- a visibility word
    private: number                 -- a field named private
end

The formatter, the highlighter, and the language server read one function, keyword_at, so the tools agree on each word.

[[ is a long string

[[ ... ]] and [=[ ... ]=] are Luau long strings. A nested array puts a space between the brackets, and the formatter writes arrays that way already:

local grid = [ [1, 2], [3, 4] ]

After an intrinsic a string cannot follow, so $map[["a", 1], ["b", 2]] and $set[[...]] keep reading the bracket as the list of items.

Headers

as is optional when the body starts on the next line or the header closes with end:

struct Player
    name: string
end

impl Named for Player
    function name(self): string return self.name end
end

enum Color as Red, Green, Blue end

A body on the header's own line keeps as, because the word is what splits the two there. The rule holds for struct, enum, trait, interface, namespace, and impl. An attribute body keeps as, because an attribute without a body is a complete declaration.

alloy fmt removes an as that has the body below it, so a project has one form. The editor indents after a header with no as. A body on the header's line with no as still reports needs `as` before its body, and the quick fix writes it.

as before a body on the next line is the style lint redundant_as, so each layout has one spelling: as joins a header to a body on the same line, and a line break opens a body below. alloy fmt and alloy flux --fix drop the word.

Imports resolve in place

An import lowers to a require on its own line, as Luau reads a require. One inside a function or a do block binds its names for that scope alone:

local function open_menu()
    import { Menu } from "./ui/menu"
    Menu.open()
end

The import_order lint, which asked for every import at the top of the file, is gone.

@deprecated takes Luau's table

@deprecated("why") takes a message, and @deprecated({ use = "mix", reason = "renamed" }) takes the table Luau's own list takes, so the lint at each call names the function to use instead.

Guards

A guard is written where, the word a for filter takes:

match hit with
    case Hit(p, Circle(r)) where r > 10 then big(p)
    case Hit(p, _) then small(p)
end

and still parses. The style lint match_guard_and reports it, and alloy flux --fix writes where. The spellings of other languages report with the Alloy form: an if guard names where, case A | B names or, and Ok(v) => f(v) names case Ok(v) then.

Patterns

A binding or _ after literal arms makes the match exhaustive. A guarded arm still proves nothing.

A bare name in a pattern binds a new name. When the name is in SCREAMING_CASE, the author meant a constant, so it is an error. A lowercase local in scope is the lint pattern_shadows_local, because binding over a local is sometimes meant:

error(4.2): ExhaustiveMatch: `MAX` names a value, and a bare name in a
pattern binds a new one, so this arm takes every value; compare in a
guard: `case n where n == MAX`

Arms that run statements

An arm of a match that gives a value can run statements first. The last line of the arm is its value, as in a Rust block:

local label = match score with
    case 0 then "none"
    default
        print(score)
        tostring(score)
end

return leaves the nearest function body, try do, or async do. It never leaves an arm. So in an arm, return nil leaves the function around the match, and break and continue go to the loop around it. One word keeps one meaning.

Luau has no expression that runs a statement. A match with such an arm therefore stands in three places: after local x =, after x =, and after return. The compiler moves each arm into that statement, and every line stays where it is:

local label do local _m1 = score
    if _m1 == 0 then label = "none"
    else
        print(score)
        label = tostring(score)
end end

In any other place the match is an error that says to bind it to a local first. An arm that ends in a statement other than return, break, or continue reports this arm gives no value.

In a value block (an arm, try do, or async do), a line that starts with (, [, {, -, or a string starts the value. It does not continue the line above. Luau already refuses the ( case as ambiguous. The last line of a try do or an async do is its value when it is an expression, and a call counts. Each of the two bodies runs in a function of its own, so ..., break, and continue inside it are errors.

in on a table literal

x in t searches a raw table by key. On a { } literal with items the keys are 1, 2, and so on, so the search is never what the author meant. It is an error that names the array form:

`in` on a `{ }` literal searches its keys, 1, 2, and on, not its items;
write `[ 5, 6 ]` to search the items

A keyed literal, kind in { rect = true, circle = true }, is the Lua set idiom and compiles.

@allow names

@allow reads a rustc or Clippy lint name as the Alloy lint it means, and clippy. is a tool prefix like flux.:

writtenquiets
unused_variablesunused_variable
unused_importsunused_import
dead_codeunused_function
needless_returnredundant_return
let_and_returnlocal_then_return
print_stdout, dbg_macroprint_debug
todotodo_comment
clippy.too_many_argumentstoo_many_arguments

Drawbacks

Alternatives

Prior Art